diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b7354f3f4a..8fa71ba652 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,6 +22,19 @@ updates: commit-message: prefix: chore include: scope +- package-ecosystem: npm + directory: '/login' + open-pull-requests-limit: 3 + schedule: + interval: daily + groups: + prod: + dependency-type: production + dev: + dependency-type: development + ignore: + - dependency-name: "eslint" + versions: [ "9.x" ] - package-ecosystem: gomod groups: go: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f06c4a959c..81f3104065 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,8 @@ permissions: packages: write issues: write pull-requests: write + actions: write + id-token: write jobs: core: @@ -47,6 +49,7 @@ jobs: core_cache_path: ${{ needs.core.outputs.cache_path }} console_cache_path: ${{ needs.console.outputs.cache_path }} version: ${{ needs.version.outputs.version }} + node_version: "20" core-unit-test: needs: core @@ -76,6 +79,16 @@ jobs: core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} + login-quality: + needs: [compile] + uses: ./.github/workflows/login-quality.yml + permissions: + actions: write + id-token: write + with: + ignore-run-cache: ${{ github.event_name == 'workflow_dispatch' || fromJSON(github.run_attempt) > 1 }} + node_version: "20" + container: needs: [compile] uses: ./.github/workflows/container.yml @@ -86,6 +99,16 @@ jobs: with: build_image_name: "ghcr.io/zitadel/zitadel-build" + login-container: + uses: ./.github/workflows/login-container.yml + if: ${{ github.event_name == 'workflow_dispatch' }} + permissions: + packages: write + id-token: write + with: + login_build_image_name: "ghcr.io/zitadel/zitadel-login-build" + node_version: "20" + e2e: uses: ./.github/workflows/e2e.yml needs: [compile] @@ -98,7 +121,7 @@ jobs: issues: write pull-requests: write needs: - [version, core-unit-test, core-integration-test, lint, container, e2e] + [version, core-unit-test, core-integration-test, lint, container, login-container, login-quality, e2e] if: ${{ github.event_name == 'workflow_dispatch' }} secrets: GCR_JSON_KEY_BASE64: ${{ secrets.GCR_JSON_KEY_BASE64 }} @@ -109,3 +132,6 @@ jobs: semantic_version: "23.0.7" image_name: "ghcr.io/zitadel/zitadel" google_image_name: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel" + build_image_name_login: ${{ needs.login-container.outputs.login_build_image }} + image_name_login: "ghcr.io/zitadel/zitadel-login" + google_image_name_login: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel-login" diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index 519586b9ee..7b64427a18 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -18,7 +18,9 @@ on: version: required: true type: string - + node_version: + required: true + type: string jobs: executable: runs-on: ubuntu-latest @@ -73,10 +75,38 @@ jobs: with: name: zitadel-${{ matrix.goos }}-${{ matrix.goarch }} path: zitadel-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz - + + login: + runs-on: ubuntu-latest + steps: + - + uses: actions/checkout@v4 + - + uses: depot/setup-action@v1 + with: + oidc: true + - + run: make login_standalone_out + env: + # latest if branch is main, otherwise image version which is the pull request number + LOGIN_BAKE_CLI: depot bake + DEPOT_PROJECT_ID: w47wkxzdtw + NODE_VERSION: ${{ inputs.node_version }} + - + name: move files + run: | + cp login/LICENSE login/apps/login/standalone/ + cp login/README.md login/apps/login/standalone/ + tar -czvf login.tar.gz -C login/apps/login/standalone . + - + uses: actions/upload-artifact@v4 + with: + name: login + path: login.tar.gz + checksums: runs-on: ubuntu-latest - needs: executable + needs: [executable, login] steps: - uses: actions/download-artifact@v4 diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 13e7c0dee7..c864c650a7 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -25,6 +25,7 @@ env: internal/api/assets/router.go openapi/v2 pkg/grpc/**/*.pb.* + pkg/grpc/**/*.connect.go jobs: build: diff --git a/.github/workflows/login-container.yml b/.github/workflows/login-container.yml new file mode 100644 index 0000000000..5cc841bff4 --- /dev/null +++ b/.github/workflows/login-container.yml @@ -0,0 +1,70 @@ +name: Login Container + +on: + workflow_call: + inputs: + login_build_image_name: + description: 'The image repository name of the standalone login image' + type: string + required: true + node_version: + required: true + type: string + outputs: + login_build_image: + description: 'The full image tag of the standalone login image' + value: '${{ inputs.login_build_image_name }}:${{ github.sha }}' + +permissions: + packages: write + +env: + default_labels: | + org.opencontainers.image.documentation=https://zitadel.com/docs + org.opencontainers.image.vendor=CAOS AG + org.opencontainers.image.licenses=MIT + +jobs: + login-container: + name: Build Login Container + runs-on: depot-ubuntu-22.04-8 + permissions: + id-token: write + packages: write + steps: + - uses: actions/checkout@v4 + - uses: depot/setup-action@v1 + with: + oidc: true + - name: Login meta + id: login-meta + uses: docker/metadata-action@v5 + with: + images: ${{ inputs.login_build_image_name }} + labels: ${{ env.default_labels}} + annotations: | + manifest:org.opencontainers.image.licenses=MIT + tags: | + type=sha,prefix=,suffix=,format=long + - name: Login to Docker registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Bake login multi-arch + uses: depot/bake-action@v1 + env: + NODE_VERSION: ${{ inputs.node_version }} + with: + push: true + provenance: true + sbom: true + targets: login-standalone + set: login-*.context=./login/ + project: w47wkxzdtw + files: | + ./login/docker-bake.hcl + ./login/docker-bake-release.hcl + ./docker-bake.hcl + cwd://${{ steps.login-meta.outputs.bake-file }} diff --git a/.github/workflows/login-quality.yml b/.github/workflows/login-quality.yml new file mode 100644 index 0000000000..0b4fea73f4 --- /dev/null +++ b/.github/workflows/login-quality.yml @@ -0,0 +1,59 @@ +name: Login Quality + +on: + workflow_call: + inputs: + ignore-run-cache: + description: 'Ignore run caches' + type: boolean + required: true + node_version: + required: true + type: string +jobs: + quality: + name: Ensure Quality + runs-on: depot-ubuntu-22.04-8 + timeout-minutes: 30 + permissions: + id-token: write + actions: write + env: + CACHE_DIR: /tmp/login-run-caches + steps: + - uses: actions/checkout@v4 + - uses: depot/setup-action@v1 + with: + oidc: true + - name: Restore Run Caches + uses: actions/cache/restore@v4 + id: run-caches-restore + with: + path: ${{ env.CACHE_DIR }} + key: ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}-${{github.run_attempt}} + restore-keys: | + ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}- + ${{ runner.os }}-login-run-caches-${{github.ref_name}}- + ${{ runner.os }}-login-run-caches- + - uses: actions/download-artifact@v4 + with: + path: .artifacts + name: zitadel-linux-amd64 + - name: Unpack executable + run: | + tar -xvf .artifacts/zitadel-linux-amd64.tar.gz + mv zitadel-linux-amd64/zitadel ./zitadel + - run: make login_quality + env: + # latest if branch is main, otherwise image version which is the pull request number + LOGIN_BAKE_CLI: depot bake + DEPOT_PROJECT_ID: w47wkxzdtw + IGNORE_RUN_CACHE: ${{ github.event.inputs.ignore-run-cache }} + NODE_VERSION: ${{ inputs.node_version }} + + - name: Save Run Caches + uses: actions/cache/save@v4 + with: + path: ${{ env.CACHE_DIR }} + key: ${{ steps.run-caches-restore.outputs.cache-primary-key }} + if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e40ae8805..e23c8869c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,15 @@ on: google_image_name: required: true type: string + build_image_name_login: + required: true + type: string + image_name_login: + required: true + type: string + google_image_name_login: + required: true + type: string secrets: GCR_JSON_KEY_BASE64: description: 'base64 endcrypted key to connect to Google' @@ -96,6 +105,12 @@ jobs: docker buildx imagetools create \ --tag ${{ inputs.google_image_name }}:${{ needs.version.outputs.version }} \ ${{ inputs.build_image_name }} + docker buildx imagetools create \ + --tag ${{ inputs.image_name_login }}:${{ needs.version.outputs.version }} \ + ${{ inputs.build_image_name_login }} + docker buildx imagetools create \ + --tag ${{ inputs.google_image_name_login }}:${{ needs.version.outputs.version }} \ + ${{ inputs.build_image_name_login }} - name: Publish latest if: ${{ github.ref_name == 'next' }} @@ -106,6 +121,9 @@ jobs: docker buildx imagetools create \ --tag ${{ inputs.image_name }}:latest-debug \ ${{ inputs.build_image_name }}-debug + docker buildx imagetools create \ + --tag ${{ inputs.image_name_login }}:latest \ + ${{ inputs.build_image_name_login }} homebrew-tap: runs-on: ubuntu-22.04 @@ -146,3 +164,55 @@ jobs: GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | gh workflow -R zitadel/zitadel-charts run bump.yml + + typescript-packages: + runs-on: ubuntu-latest + needs: version + if: ${{ github.ref_name == 'next' }} + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + working-directory: login + run: pnpm install + + - name: Create Release Pull Request + uses: changesets/action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + version: ${{ needs.version.outputs.version }} + cwd: login + + typescript-repo: + runs-on: ubuntu-latest + needs: version + if: ${{ github.ref_name == 'next' }} + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Push Subtree + run: make login_push LOGIN_REMOTE_BRANCH=mirror-zitadel-repo + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: mirror zitadel repo' + branch: mirror-zitadel-repo + title: 'chore: mirror zitadel repo' + body: 'This PR updates the login repository with the latest changes from the zitadel repository.' + base: main + reviewers: | + @peintnermax + @eliobischof diff --git a/.gitignore b/.gitignore index 23469d4209..0aa6cc1976 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,8 @@ console/src/app/proto/generated/ !pkg/grpc/protoc/v2/options.pb.go **.proto.mock.go **.pb.*.go -**.gen.go +pkg/**/**.connect.go +**.gen.go openapi/**/*.json /internal/api/assets/authz.go /internal/api/assets/router.go diff --git a/.golangci.yaml b/.golangci.yaml index 1cae359605..a4d5fd95d4 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -20,6 +20,7 @@ issues: - openapi - proto - tools + - login run: concurrency: 4 diff --git a/API_DESIGN.md b/API_DESIGN.md index 11b7766a49..cdf43a71df 100644 --- a/API_DESIGN.md +++ b/API_DESIGN.md @@ -135,6 +135,8 @@ message CreateUserRequest { ``` Only allow providing a context where it is required. The context MUST not be provided if not required. +If the context is required but deferrable, the context can be defaulted. +For example, creating an Authorization without an organization id will default the organization id to the projects resource owner. For example, when retrieving or updating a user, the `organization_id` is not required, since the user can be determined by the user's id. However, it is possible to provide the `organization_id` as a filter to retrieve a list of users of a specific organization. diff --git a/LICENSING.md b/LICENSING.md index 9cad2082f8..259a0d5070 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -18,6 +18,13 @@ The following files and directories, including their subdirectories, are license proto/ ``` + +The following files and directories, including their subdirectories, are licensed under the [MIT License](https://opensource.org/license/mit/): + +``` +login/ +``` + ## Community Contributions To maintain a clear licensing structure and facilitate community contributions, all contributions must be licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) to be accepted. By submitting a contribution, you agree to this licensing. diff --git a/Makefile b/Makefile index 3c50231bee..3bad5aa1c6 100644 --- a/Makefile +++ b/Makefile @@ -12,11 +12,21 @@ ZITADEL_MASTERKEY ?= MasterkeyNeedsToHave32Characters export GOCOVERDIR ZITADEL_MASTERKEY +LOGIN_REMOTE_NAME := login +LOGIN_REMOTE_URL ?= https://github.com/zitadel/typescript.git +LOGIN_REMOTE_BRANCH ?= main + .PHONY: compile compile: core_build console_build compile_pipeline .PHONY: docker_image -docker_image: compile +docker_image: + @if [ ! -f ./zitadel ]; then \ + echo "Compiling zitadel binary"; \ + $(MAKE) compile; \ + else \ + echo "Reusing precompiled zitadel binary"; \ + fi DOCKER_BUILDKIT=1 docker build -f build/Dockerfile -t $(ZITADEL_IMAGE) . .PHONY: compile_pipeline @@ -68,12 +78,13 @@ core_grpc_dependencies: go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.22.0 # https://pkg.go.dev/github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2?tab=versions go install github.com/envoyproxy/protoc-gen-validate@v1.1.0 # https://pkg.go.dev/github.com/envoyproxy/protoc-gen-validate?tab=versions go install github.com/bufbuild/buf/cmd/buf@v1.45.0 # https://pkg.go.dev/github.com/bufbuild/buf/cmd/buf?tab=versions + go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.18.1 # https://pkg.go.dev/connectrpc.com/connect/cmd/protoc-gen-connect-go?tab=versions .PHONY: core_api core_api: core_api_generator core_grpc_dependencies buf generate mkdir -p pkg/grpc - cp -r .artifacts/grpc/github.com/zitadel/zitadel/pkg/grpc/* pkg/grpc/ + cp -r .artifacts/grpc/github.com/zitadel/zitadel/pkg/grpc/** pkg/grpc/ mkdir -p openapi/v2/zitadel cp -r .artifacts/grpc/zitadel/ openapi/v2/zitadel @@ -165,3 +176,41 @@ core_lint: --config ./.golangci.yaml \ --out-format=github-actions \ --concurrency=$$(getconf _NPROCESSORS_ONLN) + +.PHONY: login_pull +login_pull: login_ensure_remote + @echo "Pulling changes from the 'login' subtree on remote $(LOGIN_REMOTE_NAME) branch $(LOGIN_REMOTE_BRANCH)" + git fetch $(LOGIN_REMOTE_NAME) + git subtree pull --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH) + +.PHONY: login_push +login_push: login_ensure_remote + @echo "Pushing changes to the 'login' subtree on remote $(LOGIN_REMOTE_NAME) branch $(LOGIN_REMOTE_BRANCH)" + git subtree push --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH) + +login_ensure_remote: + @if ! git remote get-url $(LOGIN_REMOTE_NAME) > /dev/null 2>&1; then \ + echo "Adding remote $(LOGIN_REMOTE_NAME)"; \ + git remote add $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_URL); \ + else \ + echo "Remote $(LOGIN_REMOTE_NAME) already exists."; \ + fi + @if [ ! -d login ]; then \ + echo "Adding subtree for 'login' from branch $(LOGIN_REMOTE_BRANCH)"; \ + git subtree add --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH); \ + else \ + echo "Subtree 'login' already exists."; \ + fi + +export LOGIN_DIR := ./login/ +export LOGIN_BAKE_CLI_ADDITIONAL_ARGS := --set login-*.context=./login/ --file ./docker-bake.hcl +export ZITADEL_TAG ?= $(ZITADEL_IMAGE) +include login/Makefile + +# Intentional override of login_test_acceptance_build +login_test_acceptance_build: docker_image + @echo "Building login test acceptance environment with the local zitadel image" + $(MAKE) login_test_acceptance_build_compose login_test_acceptance_build_bake + +login_dev: docker_image typescript_generate login_test_acceptance_build_compose login_test_acceptance_cleanup login_test_acceptance_setup_dev + @echo "Starting login test environment with the local zitadel image" diff --git a/buf.gen.yaml b/buf.gen.yaml index 858a1e6404..5a29ba9cd3 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -19,3 +19,5 @@ plugins: out: .artifacts/grpc - plugin: zitadel out: .artifacts/grpc + - plugin: connect-go + out: .artifacts/grpc diff --git a/build/Dockerfile.gitignore b/build/Dockerfile.gitignore new file mode 100644 index 0000000000..a2cc8ed480 --- /dev/null +++ b/build/Dockerfile.gitignore @@ -0,0 +1,3 @@ +* +!build/entrypoint.sh +!zitadel diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 7bb44b743f..2faf42770b 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -526,13 +526,13 @@ OIDC: CharSet: "BCDFGHJKLMNPQRSTVWXZ" # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARSET CharAmount: 8 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARARMOUNT DashInterval: 4 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_DASHINTERVAL - DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 - DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 + DefaultLogoutURLV2: "/ui/v2/login/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 PublicKeyCacheMaxAge: 24h # ZITADEL_OIDC_PUBLICKEYCACHEMAXAGE DefaultBackChannelLogoutLifetime: 15m # ZITADEL_OIDC_DEFAULTBACKCHANNELLOGOUTLIFETIME SAML: - DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 + DefaultLoginURLV2: "/ui/v2/login/login?samlRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 ProviderConfig: MetadataConfig: Path: "/metadata" # ZITADEL_SAML_PROVIDERCONFIG_METADATACONFIG_PATH @@ -839,6 +839,13 @@ DefaultInstance: Pat: # date format: 2023-01-01T00:00:00Z ExpirationDate: # ZITADEL_DEFAULTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE + LoginClient: + Machine: + Username: # ZITADEL_DEFAULTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME + Name: # ZITADEL_DEFAULTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME + Pat: + # date format: 2023-01-01T00:00:00Z + ExpirationDate: # ZITADEL_DEFAULTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE SecretGenerators: ClientSecret: Length: 64 # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_CLIENTSECRET_LENGTH @@ -1131,8 +1138,8 @@ DefaultInstance: # OIDCSingleV1SessionTermination: false # ZITADEL_DEFAULTINSTANCE_FEATURES_OIDCSINGLEV1SESSIONTERMINATION # DisableUserTokenEvent: false # ZITADEL_DEFAULTINSTANCE_FEATURES_DISABLEUSERTOKENEVENT # EnableBackChannelLogout: false # ZITADEL_DEFAULTINSTANCE_FEATURES_ENABLEBACKCHANNELLOGOUT - # LoginV2: - # Required: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED + LoginV2: + Required: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED # BaseURI: "" # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI # PermissionCheckV2: false # ZITADEL_DEFAULTINSTANCE_FEATURES_PERMISSIONCHECKV2 # ConsoleUseV2UserApi: false # ZITADEL_DEFAULTINSTANCE_FEATURES_CONSOLEUSEV2USERAPI @@ -1196,6 +1203,40 @@ DefaultInstance: # If an audit log retention is set using an instance limit, it will overwrite the system default. AuditLogRetention: 0s # ZITADEL_AUDITLOGRETENTION +# The ServicePing are periodic reports of analytics data and the usage of ZITADEL. +# It is sent to a central endpoint to help us improve ZITADEL. +# It's enabled by default, but you can opt out either completely or by disabling specific telemetry data. +ServicePing: + # By setting Enabled to false, the service ping is disabled completely. + Enabled: true # ZITADEL_SERVICEPING_ENABLED + # The endpoint to which the reports are sent. The endpoint is used as a base path. Individual reports are sent to the endpoint with a specific path. + Endpoint: "https://zitadel.com/api/ping" # ZITADEL_SERVICEPING_ENDPOINT + # Interval at which the service ping is sent to the endpoint. + # The interval is in the format of a cron expression. + # By default, it is set to every daily. + # Note that if the interval is set to `@daily`, we randomize the time to prevent all systems from sending their reports at the same time. + # If you want to send the service ping at a specific time, you can set the interval to a cron expression like "@midnight" or "15 4 * * *". + Interval: "@daily" # ZITADEL_SERVICEPING_INTERVAL + # Maximum number of attempts for each individual report to be sent. + # 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 3 attempts, + # the resource count still has 5 attempts to be sent. + MaxAttempts: 5 # ZITADEL_SERVICEPING_MAXATTEMPTS + # The following features can be enabled or disabled individually. + # By default, all features are enabled. + # Note that if the service ping is enabled, base information about the system is always sent. + # This includes the version and the id, creation date and domains of all instances. + # If you disable a feature, it will not be sent in the service ping. + # Some features provide additional configuration options, if enabled. + Telemetry: + # ResourceCount is a periodic report of the number of resources in ZITADEL. + # This includes the number of users, organizations, projects, and other resources. + ResourceCount: + Enabled: true # ZITADEL_SERVICEPING_TELEMETRY_RESOURCECOUNT_ENABLED + # The number of counts that are sent in one batch. + BulkSize: 10000 # ZITADEL_SERVICEPING_TELEMETRY_RESOURCECOUNT_BULKSIZE + InternalAuthZ: # Configure the RolePermissionMappings by environment variable using JSON notation: # ZITADEL_INTERNALAUTHZ_ROLEPERMISSIONMAPPINGS='[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write"]}]' diff --git a/cmd/setup/03.go b/cmd/setup/03.go index 588ac71610..e8c51c79c6 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -20,12 +20,13 @@ import ( ) type FirstInstance struct { - InstanceName string - DefaultLanguage language.Tag - Org command.InstanceOrgSetup - MachineKeyPath string - PatPath string - Features *command.InstanceFeatures + InstanceName string + DefaultLanguage language.Tag + Org command.InstanceOrgSetup + MachineKeyPath string + PatPath string + LoginClientPatPath string + Features *command.InstanceFeatures Skip bool @@ -121,16 +122,18 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error } } - _, token, key, _, err := cmd.SetUpInstance(ctx, &mig.instanceSetup) + _, token, key, loginClientToken, _, err := cmd.SetUpInstance(ctx, &mig.instanceSetup) if err != nil { return err } - if mig.instanceSetup.Org.Machine != nil && + if (mig.instanceSetup.Org.Machine != nil && ((mig.instanceSetup.Org.Machine.Pat != nil && token == "") || - (mig.instanceSetup.Org.Machine.MachineKey != nil && key == nil)) { + (mig.instanceSetup.Org.Machine.MachineKey != nil && key == nil))) || + (mig.instanceSetup.Org.LoginClient != nil && + (mig.instanceSetup.Org.LoginClient.Pat != nil && loginClientToken == "")) { return err } - return mig.outputMachineAuthentication(key, token) + return mig.outputMachineAuthentication(key, token, loginClientToken) } func (mig *FirstInstance) verifyEncryptionKeys(ctx context.Context) (*crypto_db.Database, error) { @@ -150,7 +153,7 @@ func (mig *FirstInstance) verifyEncryptionKeys(ctx context.Context) (*crypto_db. return keyStorage, nil } -func (mig *FirstInstance) outputMachineAuthentication(key *command.MachineKey, token string) error { +func (mig *FirstInstance) outputMachineAuthentication(key *command.MachineKey, token, loginClientToken string) error { if key != nil { keyDetails, err := key.Detail() if err != nil { @@ -165,6 +168,11 @@ func (mig *FirstInstance) outputMachineAuthentication(key *command.MachineKey, t return err } } + if loginClientToken != "" { + if err := outputStdoutOrPath(mig.LoginClientPatPath, loginClientToken); err != nil { + return err + } + } return nil } diff --git a/cmd/setup/60.go b/cmd/setup/60.go new file mode 100644 index 0000000000..3f606c2212 --- /dev/null +++ b/cmd/setup/60.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/serviceping" + "github.com/zitadel/zitadel/internal/v2/system" +) + +type GenerateSystemID struct { + eventstore *eventstore.Eventstore +} + +func (mig *GenerateSystemID) Execute(ctx context.Context, _ eventstore.Event) error { + id, err := serviceping.GenerateSystemID() + if err != nil { + return err + } + _, err = mig.eventstore.Push(ctx, system.NewIDGeneratedEvent(ctx, id)) + return err +} + +func (mig *GenerateSystemID) String() string { + return "60_generate_system_id" +} diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 7385cc7652..bac73b0ae5 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -156,6 +156,7 @@ type Steps struct { s57CreateResourceCounts *CreateResourceCounts s58ReplaceLoginNames3View *ReplaceLoginNames3View s59SetupWebkeys *SetupWebkeys + s60GenerateSystemID *GenerateSystemID } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/config_test.go b/cmd/setup/config_test.go index 8cf241cd6a..6c087fe402 100644 --- a/cmd/setup/config_test.go +++ b/cmd/setup/config_test.go @@ -36,8 +36,6 @@ func TestMustNewConfig(t *testing.T) { DefaultInstance: Features: LoginDefaultOrg: true - LegacyIntrospection: true - TriggerIntrospectionProjections: true UserSchema: true Log: Level: info @@ -47,10 +45,8 @@ Actions: `}, want: func(t *testing.T, config *Config) { assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - LegacyIntrospection: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(true), - UserSchema: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) }, }, { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index dd23c320c7..15236a73e9 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -217,6 +217,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s56IDPTemplate6SAMLFederatedLogout = &IDPTemplate6SAMLFederatedLogout{dbClient: dbClient} steps.s57CreateResourceCounts = &CreateResourceCounts{dbClient: dbClient} steps.s58ReplaceLoginNames3View = &ReplaceLoginNames3View{dbClient: dbClient} + steps.s60GenerateSystemID = &GenerateSystemID{eventstore: eventstoreClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -264,6 +265,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s56IDPTemplate6SAMLFederatedLogout, steps.s57CreateResourceCounts, steps.s58ReplaceLoginNames3View, + steps.s60GenerateSystemID, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/cmd/setup/steps.yaml b/cmd/setup/steps.yaml index d2a7cc68dd..709becf2c3 100644 --- a/cmd/setup/steps.yaml +++ b/cmd/setup/steps.yaml @@ -6,6 +6,7 @@ FirstInstance: MachineKeyPath: # ZITADEL_FIRSTINSTANCE_MACHINEKEYPATH # The personal access token from the section FirstInstance.Org.Machine.Pat is written to the PatPath. PatPath: # ZITADEL_FIRSTINSTANCE_PATPATH + LoginClientPatPath: # ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH InstanceName: ZITADEL # ZITADEL_FIRSTINSTANCE_INSTANCENAME DefaultLanguage: en # ZITADEL_FIRSTINSTANCE_DEFAULTLANGUAGE Org: @@ -46,6 +47,13 @@ FirstInstance: Pat: # date format: 2023-01-01T00:00:00Z ExpirationDate: # ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE + LoginClient: + Machine: + Username: # ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME + Name: # ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME + Pat: + # date format: 2023-01-01T00:00:00Z + ExpirationDate: # ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE CorrectCreationDate: FailAfter: 5m # ZITADEL_CORRECTCREATIONDATE_FAILAFTER diff --git a/cmd/start/config.go b/cmd/start/config.go index 78b6f0afe0..c680bf7c05 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -32,6 +32,7 @@ import ( "github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/notification/handlers" "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/serviceping" static_config "github.com/zitadel/zitadel/internal/static/config" metrics "github.com/zitadel/zitadel/internal/telemetry/metrics/config" profiler "github.com/zitadel/zitadel/internal/telemetry/profiler/config" @@ -81,6 +82,7 @@ type Config struct { LogStore *logstore.Configs Quotas *QuotasConfig Telemetry *handlers.TelemetryPusherConfig + ServicePing *serviceping.Config } type QuotasConfig struct { diff --git a/cmd/start/config_test.go b/cmd/start/config_test.go index 53c95d35ab..3c8328e557 100644 --- a/cmd/start/config_test.go +++ b/cmd/start/config_test.go @@ -73,8 +73,6 @@ Log: DefaultInstance: Features: LoginDefaultOrg: true - LegacyIntrospection: true - TriggerIntrospectionProjections: true UserSchema: true Log: Level: info @@ -84,10 +82,8 @@ Actions: `}, want: func(t *testing.T, config *Config) { assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - LegacyIntrospection: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(true), - UserSchema: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) }, }, { diff --git a/cmd/start/start.go b/cmd/start/start.go index 8820480f0c..9c1e2a4d28 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -36,11 +36,14 @@ import ( internal_authz "github.com/zitadel/zitadel/internal/api/authz" action_v2_beta "github.com/zitadel/zitadel/internal/api/grpc/action/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/admin" + app "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/auth" + authorization_v2beta "github.com/zitadel/zitadel/internal/api/grpc/authorization/v2beta" feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta" idp_v2 "github.com/zitadel/zitadel/internal/api/grpc/idp/v2" instance "github.com/zitadel/zitadel/internal/api/grpc/instance/v2beta" + internal_permission_v2beta "github.com/zitadel/zitadel/internal/api/grpc/internal_permission/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/management" oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2" oidc_v2beta "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2beta" @@ -58,7 +61,8 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/system" user_v2 "github.com/zitadel/zitadel/internal/api/grpc/user/v2" user_v2beta "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta" - webkey "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2beta" + webkey_v2 "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2" + webkey_v2beta "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2beta" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/idp" @@ -98,6 +102,7 @@ import ( "github.com/zitadel/zitadel/internal/notification" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/queue" + "github.com/zitadel/zitadel/internal/serviceping" "github.com/zitadel/zitadel/internal/static" es_v4 "github.com/zitadel/zitadel/internal/v2/eventstore" es_v4_pg "github.com/zitadel/zitadel/internal/v2/eventstore/postgres" @@ -316,10 +321,20 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server ) execution.Start(ctx) + // the service ping and it's workers need to be registered before starting the queue + if err := serviceping.Register(ctx, q, queries, eventstoreClient, config.ServicePing); err != nil { + return err + } + if err = q.Start(ctx); err != nil { return err } + // the scheduler / periodic jobs need to be started after the queue already runs + if err = serviceping.Start(config.ServicePing, q); err != nil { + return err + } + router := mux.NewRouter() tlsConfig, err := config.TLS.Config() if err != nil { @@ -461,7 +476,7 @@ func startAPIs( if err := apis.RegisterService(ctx, user_v2beta.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, user_v2.CreateServer(config.SystemDefaults, commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { + if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, config.SystemDefaults, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { return nil, err } if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil { @@ -497,18 +512,31 @@ func startAPIs( if err := apis.RegisterService(ctx, project_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, internal_permission_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { + return nil, err + } if err := apis.RegisterService(ctx, userschema_v3_alpha.CreateServer(config.SystemDefaults, commands, queries)); err != nil { return nil, err } if err := apis.RegisterService(ctx, user_v3_alpha.CreateServer(commands)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, webkey.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, webkey_v2beta.CreateServer(commands, queries)); err != nil { + return nil, err + } + if err := apis.RegisterService(ctx, webkey_v2.CreateServer(commands, queries)); err != nil { return nil, err } if err := apis.RegisterService(ctx, debug_events.CreateServer(commands, queries)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, authorization_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { + return nil, err + } + if err := apis.RegisterService(ctx, app.CreateServer(commands, queries, permissionCheck)); err != nil { + return nil, err + } + instanceInterceptor := middleware.InstanceInterceptor(queries, config.ExternalDomain, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.SystemAuthZ, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) @@ -550,7 +578,6 @@ func startAPIs( keys.OIDC, keys.OIDCKey, eventstore, - dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor, diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index 4094172f79..07e987af79 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -33,13 +33,10 @@ const FEATURE_KEYS = [ 'enableBackChannelLogout', // 'improvedPerformance', 'loginDefaultOrg', - 'oidcLegacyIntrospection', 'oidcSingleV1SessionTermination', 'oidcTokenExchange', - 'oidcTriggerIntrospectionProjections', 'permissionCheckV2', 'userSchema', - 'webKey', ] as const; export type ToggleState = { source: Source; enabled: boolean }; diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index dc3dc04193..2d51fa2571 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1623,12 +1623,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Организация по подразбиране за влизане", "LOGINDEFAULTORG_DESCRIPTION": "Потребителският интерфейс за влизане ще използва настройките на организацията по подразбиране (а не на инстанцията), ако не е зададен контекст на организация.", - "OIDCLEGACYINTROSPECTION": "Наследено осмисляне OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "На скоро префакторизирахме крайния пункт за осмисляне заради производителностни причини. Тази функция може да се използва за връщане към наследената реализация, ако възникнат неочаквани грешки.", "OIDCTOKENEXCHANGE": "Обмяна на токени OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Активиране на експерименталния тип на дарение urn:ietf:params:oauth:grant-type:token-exchange за краен пункт на токен OIDC. Обменът на токени може да се използва за заявка на токени с по-малък обхват или за имперсонализиране на други потребители. Вижте политиката за сигурност, за да разрешите имперсонализацията на инстанция.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Тригери за проекции на осмисляне на OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Активиране на тригери за проекции по време на заявка за осмисляне. Това може да действа като обходен механизъм, ако има забележими проблеми с консистентността в отговора на осмислянето, но може да окаже влияние върху производителността. Планираме да премахнем тригерите за заявки за осмисляне в бъдеще.", "USERSCHEMA": "Потребителска схема", "USERSCHEMA_DESCRIPTION": "Потребителските схеми позволяват управление на данните за схемите на потребителите. Ако е активиран флагът, ще можете да използвате новото API и неговите функции.", "ACTIONS": "Действия", @@ -1643,8 +1639,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout имплементира OpenID Connect Back-Channel Logout 1.0 и може да се използва за уведомяване на клиентите за прекратяване на сесията при OpenID доставчика.", "PERMISSIONCHECKV2": "Проверка на разрешения V2", "PERMISSIONCHECKV2_DESCRIPTION": "Ако флагът е активиран, ще можете да използвате новия API и неговите функции.", - "WEBKEY": "Уеб ключ", - "WEBKEY_DESCRIPTION": "Ако флагът е активиран, ще можете да използвате новия API и неговите функции.", "STATES": { "INHERITED": "Наследено", "ENABLED": "Активирано", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 6c85389c60..cfa342f548 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1624,12 +1624,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Výchozí organizace pro přihlášení", "LOGINDEFAULTORG_DESCRIPTION": "Přihlašovací rozhraní použije nastavení výchozí organizace (a ne z instance), pokud není nastaven žádný kontext organizace.", - "OIDCLEGACYINTROSPECTION": "Dědictví OIDC introspekce", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Nedávno jsme přepracovali bod introspekce z výkonnostních důvodů. Tato funkce lze použít k rollbacku na dědickou implementaci, pokud se objeví neočekávané chyby.", "OIDCTOKENEXCHANGE": "Výměna tokenů OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Povolit experimentální typ udělení urn:ietf:params:oauth:grant-type:token-exchange pro bod tokenového bodu OIDC. Výměna tokenů lze použít k žádosti o tokeny s menším rozsahem nebo k impersonaci jiných uživatelů. Podívejte se na bezpečnostní politiku, abyste umožnili impersonaci na instanci.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Spouštěče projekcí introspekce OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Povolit spouštěče projekcí během požadavku na introspekci. To může sloužit jako obcházení, pokud existují zjevné problémy s konzistencí v odpovědi na introspekci, ale může to mít vliv na výkon. Plánujeme odstranit spouštěče pro požadavky na introspekci v budoucnosti.", "USERSCHEMA": "Schéma uživatele", "USERSCHEMA_DESCRIPTION": "Schémata uživatelů umožňují spravovat datová schémata uživatelů. Pokud je příznak povolen, budete moci používat nové API a jeho funkce.", "ACTIONS": "Akce", @@ -1644,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout implementuje OpenID Connect Back-Channel Logout 1.0 a může být použit k informování klientů o ukončení relace u poskytovatele OpenID.", "PERMISSIONCHECKV2": "Kontrola oprávnění V2", "PERMISSIONCHECKV2_DESCRIPTION": "Pokud je příznak povolen, budete moci používat nový API a jeho funkce.", - "WEBKEY": "Webový klíč", - "WEBKEY_DESCRIPTION": "Pokud je příznak povolen, budete moci používat nový API a jeho funkce.", "STATES": { "INHERITED": "Děděno", "ENABLED": "Povoleno", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index a1f449c1c2..ad278c5863 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1624,12 +1624,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Standardorganisation für die Anmeldung", "LOGINDEFAULTORG_DESCRIPTION": "Die Anmelde-Benutzeroberfläche verwendet die Einstellungen der Standardorganisation (und nicht von der Instanz), wenn kein Organisationskontext festgelegt ist.", - "OIDCLEGACYINTROSPECTION": "OIDC Legacy-Introspektion", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Wir haben kürzlich den Introspektionsendpunkt aus Leistungsgründen neu strukturiert. Mit diesem Feature können Sie zur alten Implementierung zurückkehren, falls unerwartete Fehler auftreten.", "OIDCTOKENEXCHANGE": "OIDC Token-Austausch", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktivieren Sie den experimentellen urn:ietf:params:oauth:grant-type:token-exchange-Grant-Typ für den OIDC-Token-Endpunkt. Der Token-Austausch kann verwendet werden, um Token mit einem geringeren Umfang anzufordern oder andere Benutzer zu impersonieren. Siehe die Sicherheitsrichtlinie, um die Impersonation auf einer Instanz zu erlauben.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger-Introspektionsprojektionen", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Aktivieren Sie Projektionstrigger während einer Introspektionsanfrage. Dies kann als Workaround fungieren, wenn bemerkbare Konsistenzprobleme in der Introspektionsantwort auftreten, kann sich jedoch auf die Leistung auswirken. Wir planen, Trigger für Introspektionsanfragen in Zukunft zu entfernen.", "USERSCHEMA": "Benutzerschema", "USERSCHEMA_DESCRIPTION": "Benutzerschemata ermöglichen das Verwalten von Datenschemata von Benutzern. Wenn die Flagge aktiviert ist, können Sie die neue API und ihre Funktionen verwenden.", "ACTIONS": "Aktionen", @@ -1644,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Der Back-Channel-Logout implementiert OpenID Connect Back-Channel Logout 1.0 und kann verwendet werden, um Clients über die Beendigung der Sitzung beim OpenID-Provider zu benachrichtigen.", "PERMISSIONCHECKV2": "Berechtigungsprüfung V2", "PERMISSIONCHECKV2_DESCRIPTION": "Wenn die Flagge aktiviert ist, können Sie die neue API und ihre Funktionen verwenden.", - "WEBKEY": "Web-Schlüssel", - "WEBKEY_DESCRIPTION": "Wenn die Flagge aktiviert ist, können Sie die neue API und ihre Funktionen verwenden.", "STATES": { "INHERITED": "Erben", "ENABLED": "Aktiviert", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 9e8dadd416..6e584f9336 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1627,12 +1627,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Login Default Org", "LOGINDEFAULTORG_DESCRIPTION": "The login UI will use the settings of the default org (and not from the instance) if no organization context is set", - "OIDCLEGACYINTROSPECTION": "OIDC Legacy introspection", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise.", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Enable the experimental urn:ietf:params:oauth:grant-type:token-exchange grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger introspection Projections", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future.", "USERSCHEMA": "User Schema", "USERSCHEMA_DESCRIPTION": "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features.", "ACTIONS": "Actions", @@ -1647,8 +1643,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "The Back-Channel Logout implements OpenID Connect Back-Channel Logout 1.0 and can be used to notify clients about session termination at the OpenID Provider.", "PERMISSIONCHECKV2": "Permission Check V2", "PERMISSIONCHECKV2_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", - "WEBKEY": "Web Key", - "WEBKEY_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", "STATES": { "INHERITED": "Inherit", "ENABLED": "Enabled", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index d5493f5d70..55007b3086 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1625,12 +1625,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Organización predeterminada de inicio de sesión", "LOGINDEFAULTORG_DESCRIPTION": "La interfaz de inicio de sesión utilizará la configuración de la organización predeterminada (y no de la instancia) si no se establece ningún contexto de organización.", - "OIDCLEGACYINTROSPECTION": "Introspección heredada OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Recientemente hemos refactorizado el punto de introspección por razones de rendimiento. Esta función se puede utilizar para volver a la implementación heredada si surgen errores inesperados.", "OIDCTOKENEXCHANGE": "Intercambio de tokens OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Habilita el tipo de concesión experimental urn:ietf:params:oauth:grant-type:token-exchange para el punto de extremo de token OIDC. El intercambio de tokens se puede utilizar para solicitar tokens con un alcance menor o suplantar a otros usuarios. Consulta la política de seguridad para permitir la suplantación en una instancia.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Desencadenadores de proyecciones de introspección OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Habilita los desencadenadores de proyección durante una solicitud de introspección. Esto puede actuar como un mecanismo alternativo si hay problemas de coherencia perceptibles en la respuesta a la introspección, pero puede afectar al rendimiento. Estamos planeando eliminar los desencadenadores para las solicitudes de introspección en el futuro.", "USERSCHEMA": "Esquema de usuario", "USERSCHEMA_DESCRIPTION": "Los esquemas de usuario permiten gestionar los esquemas de datos de los usuarios. Si se activa la bandera, podrás utilizar la nueva API y sus funciones.", "ACTIONS": "Acciones", @@ -1645,8 +1641,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "El Back-Channel Logout implementa OpenID Connect Back-Channel Logout 1.0 y se puede usar para notificar a los clientes sobre la terminación de la sesión en el proveedor de OpenID.", "PERMISSIONCHECKV2": "Verificación de permisos V2", "PERMISSIONCHECKV2_DESCRIPTION": "Si la bandera está habilitada, podrá usar la nueva API y sus funciones.", - "WEBKEY": "Clave web", - "WEBKEY_DESCRIPTION": "Si la bandera está habilitada, podrá usar la nueva API y sus funciones.", "STATES": { "INHERITED": "Heredado", "ENABLED": "Habilitado", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index c0a17ac19d..cda6a044ff 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1624,12 +1624,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Organisation par défaut de connexion", "LOGINDEFAULTORG_DESCRIPTION": "L'interface de connexion utilisera les paramètres de l'organisation par défaut (et non de l'instance) si aucun contexte d'organisation n'est défini.", - "OIDCLEGACYINTROSPECTION": "Introspection héritée OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Nous avons récemment refondu le point d'introspection pour des raisons de performances. Cette fonctionnalité peut être utilisée pour revenir à l'implémentation héritée en cas de bogues inattendus.", "OIDCTOKENEXCHANGE": "Échange de jetons OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Activez le type d'octroi expérimental urn:ietf:params:oauth:grant-type:token-exchange pour le point de terminaison de jeton OIDC. L'échange de jetons peut être utilisé pour demander des jetons avec une portée moindre ou pour usurper d'autres utilisateurs. Consultez la politique de sécurité pour autoriser l'usurpation sur une instance.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Déclencheurs de projections d'introspection OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Activez les déclencheurs de projection lors d'une demande d'introspection. Cela peut agir comme un contournement s'il existe des problèmes de cohérence perceptibles dans la réponse à l'introspection, mais cela peut avoir un impact sur les performances. Nous prévoyons de supprimer les déclencheurs pour les demandes d'introspection à l'avenir.", "USERSCHEMA": "Schéma utilisateur", "USERSCHEMA_DESCRIPTION": "Les schémas utilisateur permettent de gérer les schémas de données des utilisateurs. Si le drapeau est activé, vous pourrez utiliser la nouvelle API et ses fonctionnalités.", "ACTIONS": "Actions", @@ -1644,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Le Back-Channel Logout implémente OpenID Connect Back-Channel Logout 1.0 et peut être utilisé pour notifier les clients de la fin de session chez le fournisseur OpenID.", "PERMISSIONCHECKV2": "Vérification des permissions V2", "PERMISSIONCHECKV2_DESCRIPTION": "Si le drapeau est activé, vous pourrez utiliser la nouvelle API et ses fonctionnalités.", - "WEBKEY": "Clé web", - "WEBKEY_DESCRIPTION": "Si le drapeau est activé, vous pourrez utiliser la nouvelle API et ses fonctionnalités.", "STATES": { "INHERITED": "Hérité", "ENABLED": "Activé", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index 2f208b82b9..133d183355 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -1622,12 +1622,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Alapértelmezett Org bejelentkezés", "LOGINDEFAULTORG_DESCRIPTION": "A bejelentkezési felület az alapértelmezett org beállításait fogja használni (és nem az instance-tól), ha nincs megadva szervezeti kontextus", - "OIDCLEGACYINTROSPECTION": "OIDC régi introspekció", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Nemrég refaktoráltuk az introspekciós végpontot a teljesítmény javítása érdekében. Ezt a funkciót használhatod a régi implementációra való visszaállításhoz, ha váratlan hibák lépnének fel.", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Engedélyezd a kísérleti urn:ietf:params:oauth:grant-type:token-exchange támogatását az OIDC token végpont számára. A token csere használható kisebb hatókörű tokenek kérésére vagy más felhasználók megszemélyesítésére. Tekintsd meg a biztonsági irányelvet az impersonáció engedélyezéséhez egy példányon.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Introspekciós Projekciók Indítása", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Engedélyezd a projekciós indítókat az introspekciós kérés során. Ez lehet egy megoldás, ha észrevehető konzisztenciaproblémák vannak az introspekciós válaszban, de hatással lehet a teljesítményre. Tervezzük, hogy a jövőben eltávolítjuk a triggereket az introspekciós kérésből.", "USERSCHEMA": "Felhasználói Séma", "USERSCHEMA_DESCRIPTION": "A Felhasználói Sémák lehetővé teszik a felhasználói adat sémák kezelését. Ha az opció engedélyezve van, használhatod az új API-t és annak funkcióit.", "ACTIONS": "Műveletek", @@ -1642,8 +1638,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "A Back-Channel Logout megvalósítja az OpenID Connect Back-Channel Logout 1.0-t, és használható az ügyfelek értesítésére a munkamenet befejezéséről az OpenID szolgáltatónál.", "PERMISSIONCHECKV2": "Engedély ellenőrzés V2", "PERMISSIONCHECKV2_DESCRIPTION": "Ha a zászló engedélyezve van, használhatja az új API-t és annak funkcióit.", - "WEBKEY": "Webkulcs", - "WEBKEY_DESCRIPTION": "Ha a zászló engedélyezve van, használhatja az új API-t és annak funkcióit.", "STATES": { "INHERITED": "Örököl", "ENABLED": "Engedélyezve", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index d1d4001813..e494beeeca 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1498,12 +1498,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Masuk Organisasi Default", "LOGINDEFAULTORG_DESCRIPTION": "UI login akan menggunakan pengaturan organisasi default (dan bukan dari instance) jika tidak ada konteks organisasi yang ditetapkan", - "OIDCLEGACYINTROSPECTION": "Introspeksi Warisan OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Kami baru-baru ini memfaktorkan ulang titik akhir introspeksi untuk alasan kinerja. Fitur ini dapat digunakan untuk melakukan rollback ke implementasi lama jika muncul bug yang tidak terduga.", "OIDCTOKENEXCHANGE": "Pertukaran Token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktifkan jenis pemberian urn:ietf:params:oauth:grant-type:token-exchange eksperimental untuk titik akhir token OIDC. Pertukaran token dapat digunakan untuk meminta token dengan cakupan yang lebih kecil atau menyamar sebagai pengguna lain. Lihat kebijakan keamanan untuk mengizinkan peniruan identitas pada sebuah instans.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Memicu Proyeksi Introspeksi", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Aktifkan pemicu proyeksi selama permintaan introspeksi. Hal ini dapat menjadi solusi jika terdapat masalah konsistensi yang nyata dalam respons introspeksi namun dapat berdampak pada kinerja. Kami berencana untuk menghilangkan pemicu permintaan introspeksi di masa depan.", "USERSCHEMA": "Skema Pengguna", "USERSCHEMA_DESCRIPTION": "Skema Pengguna memungkinkan untuk mengelola skema data pengguna. Jika tanda ini diaktifkan, Anda akan dapat menggunakan API baru dan fitur-fiturnya.", "ACTIONS": "Tindakan", @@ -1515,8 +1511,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "The Back-Channel Logout implements OpenID Connect Back-Channel Logout 1.0 and can be used to notify clients about session termination at the OpenID Provider.", "PERMISSIONCHECKV2": "Permission Check V2", "PERMISSIONCHECKV2_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", - "WEBKEY": "Web Key", - "WEBKEY_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", "STATES": { "INHERITED": "Mewarisi", "ENABLED": "Diaktifkan", "DISABLED": "Dengan disabilitas" }, "INHERITED_DESCRIPTION": "Ini menetapkan nilai ke nilai default sistem.", "INHERITEDINDICATOR_DESCRIPTION": { diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 2645afb801..fa009cac77 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1624,12 +1624,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Organizzazione predefinita per l'accesso", "LOGINDEFAULTORG_DESCRIPTION": "L'interfaccia di accesso utilizzerà le impostazioni dell'organizzazione predefinita (e non dell'istanza) se non è impostato alcun contesto organizzativo.", - "OIDCLEGACYINTROSPECTION": "Introspezione legacy OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Abbiamo recentemente ristrutturato il punto di introspezione per motivi di prestazioni. Questa funzionalità può essere utilizzata per tornare alla vecchia implementazione in caso di bug imprevisti.", "OIDCTOKENEXCHANGE": "Scambio token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Abilita il tipo di concessione sperimentale urn:ietf:params:oauth:grant-type:token-exchange per il punto finale del token OIDC. Lo scambio di token può essere utilizzato per richiedere token con uno scopo inferiore o impersonare altri utenti. Consultare la policy di sicurezza per consentire l'impersonificazione su un'istanza.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Proiezioni trigger OIDC per l'introspezione", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Abilita i trigger di proiezione durante una richiesta di introspezione. Questo può agire come soluzione alternativa se ci sono problemi di coerenza evidenti nella risposta all'introspezione, ma può influire sulle prestazioni. Stiamo pianificando di rimuovere i trigger per le richieste di introspezione in futuro.", "USERSCHEMA": "Schema utente", "USERSCHEMA_DESCRIPTION": "Gli schemi utente consentono di gestire gli schemi di dati degli utenti. Se la flag è attivata, sarà possibile utilizzare la nuova API e le sue funzionalità.", "ACTIONS": "Azioni", @@ -1644,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Il Back-Channel Logout implementa OpenID Connect Back-Channel Logout 1.0 e può essere utilizzato per notificare ai client la terminazione della sessione presso il provider OpenID.", "PERMISSIONCHECKV2": "Controllo permessi V2", "PERMISSIONCHECKV2_DESCRIPTION": "Se il flag è abilitato, potrai utilizzare la nuova API e le sue funzionalità.", - "WEBKEY": "Chiave Web", - "WEBKEY_DESCRIPTION": "Se il flag è abilitato, potrai utilizzare la nuova API e le sue funzionalità.", "STATES": { "INHERITED": "Predefinito", "ENABLED": "Abilitato", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 95855ca5fe..dc5dfd4a34 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1624,12 +1624,8 @@ "FEATURES": { "LOGINDEFAULTORG": "ログイン時の既定組織", "LOGINDEFAULTORG_DESCRIPTION": "組織コンテキストが設定されていない場合、ログイン UI は既定の組織の設定を使用します (インスタンスの設定ではなく)", - "OIDCLEGACYINTROSPECTION": "OIDC レガシーイントロスペクション", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "パフォーマンス上の理由から最近イントロスペクション エンドポイントをリファクタリングしました。この機能は、予期しないバグが発生した場合にレガシー実装にロールバックするために使用できます。", "OIDCTOKENEXCHANGE": "OIDC トークン交換", "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC トークン エンドポイント用に実験的な urn:ietf:params:oauth:grant-type:token-exchange 付与タイプを有効にします。トークン交換は、より少ないスコープを持つトークンを要求するか、他のユーザーになりすますために使用できます。インスタンスでのなりすましを許可するには、セキュリティポリシーを参照してください。", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC トリガーイントロスペクションプロジェクション", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "イントロスペクション要求中にプロジェクショントリガーを有効にします。これは、イントロスペクションレスポンスに顕著な整合性問題がある場合の回避策として機能しますが、パフォーマンスに影響を与える可能性があります。今後、イントロスペクション要求のトリガーを削除する予定です。", "USERSCHEMA": "ユーザー スキーマ", "USERSCHEMA_DESCRIPTION": "ユーザー スキーマを使用すると、ユーザーのデータスキーマを管理できます。フラグが有効になっている場合、新しい APIとその機能を使用できます。", "ACTIONS": "アクション", @@ -1644,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "バックチャネルログアウトは OpenID Connect バックチャネルログアウト 1.0 を実装し、OpenID プロバイダーでのセッション終了についてクライアントに通知するために使用できます。", "PERMISSIONCHECKV2": "権限チェック V2", "PERMISSIONCHECKV2_DESCRIPTION": "フラグが有効になっている場合、新しい API とその機能を使用できます。", - "WEBKEY": "ウェブキー", - "WEBKEY_DESCRIPTION": "フラグが有効になっている場合、新しい API とその機能を使用できます。", "STATES": { "INHERITED": "継承", "ENABLED": "有効", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index b51f14ff20..eaf9968a66 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -1624,12 +1624,8 @@ "FEATURES": { "LOGINDEFAULTORG": "로그인 기본 조직", "LOGINDEFAULTORG_DESCRIPTION": "조직 컨텍스트가 설정되지 않은 경우 로그인 UI가 기본 조직의 설정을 사용합니다 (인스턴스에서 설정되지 않음).", - "OIDCLEGACYINTROSPECTION": "OIDC 레거시 내부 조사", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "최근 내부 조사 엔드포인트를 성능을 위해 리팩토링했습니다. 예상치 못한 버그가 발생하면 이 기능을 사용하여 레거시 구현으로 롤백할 수 있습니다.", "OIDCTOKENEXCHANGE": "OIDC 토큰 교환", "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC 토큰 엔드포인트의 실험적 urn:ietf:params:oauth:grant-type:token-exchange 허용을 활성화합니다. 토큰 교환을 통해 범위가 좁은 토큰을 요청하거나 다른 사용자를 가장할 수 있습니다. 인스턴스에서 가장을 허용하는 보안 정책을 확인하세요.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 트리거 내부 조사 프로젝션", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "내부 조사 요청 중 프로젝션 트리거를 활성화합니다. 이는 내부 조사 응답에서 일관성 문제가 있는 경우 임시 해결책으로 작동할 수 있으나 성능에 영향을 미칠 수 있습니다. 향후 내부 조사 요청에 대한 트리거 제거를 계획 중입니다.", "USERSCHEMA": "사용자 스키마", "USERSCHEMA_DESCRIPTION": "사용자 스키마를 통해 사용자의 데이터 스키마를 관리할 수 있습니다. 플래그가 활성화되면 새 API 및 기능을 사용할 수 있습니다.", "ACTIONS": "액션", @@ -1644,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "백채널 로그아웃은 OpenID Connect 백채널 로그아웃 1.0을 구현하며, OpenID 제공자에서 세션 종료에 대해 클라이언트에게 알리는 데 사용할 수 있습니다.", "PERMISSIONCHECKV2": "권한 확인 V2", "PERMISSIONCHECKV2_DESCRIPTION": "플래그가 활성화되면 새로운 API와 그 기능을 사용할 수 있습니다.", - "WEBKEY": "웹 키", - "WEBKEY_DESCRIPTION": "플래그가 활성화되면 새로운 API와 그 기능을 사용할 수 있습니다.", "STATES": { "INHERITED": "상속", "ENABLED": "활성화됨", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index c89a78238d..543456df24 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1625,12 +1625,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Најава Стандардна организација", "LOGINDEFAULTORG_DESCRIPTION": "Интерфејсот за најавување ќе ги користи поставките на стандардната организација (а не од примерот) ако не е поставен контекст на организацијата", - "OIDCLEGACYINTROSPECTION": "Интроспекција на наследството на OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Неодамна ја рефакториравме крајната точка на интроспекција поради перформанси. Оваа функција може да се користи за враќање на наследната имплементација доколку се појават неочекувани грешки.", "OIDCTOKENEXCHANGE": "Размена на токени OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Овозможете го експерименталниот тип на грант urn:ietf:params:oauth:grant-type:token-exchange за крајната точка на токенот OIDC. Размената на токени може да се користи за барање токени со помал опсег или имитирање на други корисници. Погледнете ја безбедносната политика за да дозволите имитирање на пример.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Проекции за интроспекција на активирањето на OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Овозможете предизвикувачи за проекција за време на барање за интроспекција. Ова може да дејствува како заобиколување ако има забележителни проблеми со конзистентноста во одговорот на интроспекцијата, но може да има влијание врз перформансите. Планираме да ги отстраниме предизвикувачите за барањата за интроспекција во иднина.", "USERSCHEMA": "Корисничка шема", "USERSCHEMA_DESCRIPTION": "Корисничките шеми овозможуваат управување со податоци шеми на корисникот. Ако знамето е овозможено, ќе можете да го користите новиот API и неговите функции.", "ACTIONS": "Акции", @@ -1645,8 +1641,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout имплементира OpenID Connect Back-Channel Logout 1.0 и може да се користи за известување на клиентите за завршување на сесијата кај OpenID провајдерот.", "PERMISSIONCHECKV2": "Проверка на дозволи V2", "PERMISSIONCHECKV2_DESCRIPTION": "Ако знамето е овозможено, ќе можете да ја користите новата API и нејзините функции.", - "WEBKEY": "Веб клуч", - "WEBKEY_DESCRIPTION": "Ако знамето е овозможено, ќе можете да ја користите новата API и нејзините функции.", "STATES": { "INHERITED": "Наследи", "ENABLED": "Овозможено", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 17eb2241f7..f8e81a1310 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1624,12 +1624,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Standaard inlogorganisatie", "LOGINDEFAULTORG_DESCRIPTION": "Als er geen organisatiecontext is ingesteld, gebruikt de inlog-UI de instellingen van de standaardorganisatie (en niet van de instantie)", - "OIDCLEGACYINTROSPECTION": "Oude OIDC-introspectie", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "We hebben onlangs het introspectie-endpoint opnieuw gefactoreerd omwille van de prestaties. Deze functie kan worden gebruikt om terug te keren naar de oude implementatie als er onverwachte bugs optreden.", "OIDCTOKENEXCHANGE": "OIDC-tokenuitwisseling", "OIDCTOKENEXCHANGE_DESCRIPTION": "Schakel het experimentele type verlening urn:ietf:params:oauth:grant-type:token-exchange in voor het OIDC-tokenendpoint. Tokenuitwisseling kan worden gebruikt om tokens met een kleinere scope op te vragen of om zich voor te doen als andere gebruikers. Raadpleeg het beveiligingsbeleid om impersonation op een instantie toe te staan.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC-triggers voor introspectieprojecties", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Schakel projectietriggers in tijdens een introspectieverzoek. Dit kan dienen als een tijdelijke oplossing als er merkbare consistentieproblemen optreden in het introspectieantwoord, maar het kan wel prestaties beïnvloeden. We zijn van plan om triggers voor introspectieverzoeken in de toekomst te verwijderen.", "USERSCHEMA": "Gebruikerschema", "USERSCHEMA_DESCRIPTION": "Met gebruikerschema's kunt u de dataschema's van gebruikers beheren. Als de vlag is ingeschakeld, kunt u de nieuwe API en zijn functies gebruiken.", "ACTIONS": "Acties", @@ -1644,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "De Back-Channel Logout implementeert OpenID Connect Back-Channel Logout 1.0 en kan worden gebruikt om clients te informeren over het beëindigen van de sessie bij de OpenID-provider.", "PERMISSIONCHECKV2": "Permissiecontrole V2", "PERMISSIONCHECKV2_DESCRIPTION": "Als de vlag is ingeschakeld, kunt u de nieuwe API en de bijbehorende functies gebruiken.", - "WEBKEY": "Websleutel", - "WEBKEY_DESCRIPTION": "Als de vlag is ingeschakeld, kunt u de nieuwe API en de bijbehorende functies gebruiken.", "STATES": { "INHERITED": "Overgenomen", "ENABLED": "Ingeschakeld", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 33e5f5291d..def9e920b6 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1623,12 +1623,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Domyślna Organizacja Logowania", "LOGINDEFAULTORG_DESCRIPTION": "Jeśli nie ustawiono kontekstu organizacji, interfejs logowania będzie używać ustawień domyślnej organizacji (a nie instancji)", - "OIDCLEGACYINTROSPECTION": "Starsza Introspekcja OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Ostatnio przeprojektowaliśmy punkt końcowy introspekcji ze względów wydajnościowych. Ta funkcja może być używana do cofnięcia do starszej implementacji, jeśli wystąpią nieoczekiwane błędy.", "OIDCTOKENEXCHANGE": "Wymiana Tokenów OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Włącz eksperymentalny typ grantu urn:ietf:params:oauth:grant-type:token-exchange dla punktu końcowego tokena OIDC. Wymiana tokenów może być używana do żądania tokenów o mniejszym zakresie lub podszywania się za innych użytkowników. Aby zezwolić na podszywanie się na instancji, zapoznaj się z polityką bezpieczeństwa.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Projekcje Introspekcji Wyzwalane przez OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Włącz wyzwalacze projekcji podczas żądania introspekcji. Może to stanowić obejście, jeśli w odpowiedzi introspekcji występują zauważalne problemy z spójnością, ale może mieć wpływ na wydajność. Planujemy w przyszłości usunąć wyzwalacze dla żądań introspekcji.", "USERSCHEMA": "Schemat Użytkownika", "USERSCHEMA_DESCRIPTION": "Schematy użytkowników umożliwiają zarządzanie schematami danych użytkowników. Jeśli flaga jest włączona, będziesz mógł korzystać z nowego interfejsu API i jego funkcji.", "ACTIONS": "Akcje", @@ -1643,8 +1639,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout implementuje OpenID Connect Back-Channel Logout 1.0 i może być używany do powiadamiania klientów o zakończeniu sesji u dostawcy OpenID.", "PERMISSIONCHECKV2": "Sprawdzanie uprawnień V2", "PERMISSIONCHECKV2_DESCRIPTION": "Jeśli flaga jest włączona, będziesz mógł korzystać z nowego API i jego funkcji.", - "WEBKEY": "Klucz Web", - "WEBKEY_DESCRIPTION": "Jeśli flaga jest włączona, będziesz mógł korzystać z nowego API i jego funkcji.", "STATES": { "INHERITED": "Dziedziczony", "ENABLED": "Włączony", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index ccd17170e4..92363fff7b 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1625,12 +1625,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Organização Padrão de Login", "LOGINDEFAULTORG_DESCRIPTION": "A interface de login utilizará as configurações da organização padrão (e não da instância) se nenhum contexto de organização estiver definido", - "OIDCLEGACYINTROSPECTION": "Introspecção Legada OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Recentemente refatoramos o endpoint de introspecção por motivos de performance. Esse recurso pode ser usado para reverter para a implementação legada caso surjam bugs inesperados.", "OIDCTOKENEXCHANGE": "Troca de Token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Habilita o tipo de concessão experimental urn:ietf:params:oauth:grant-type:token-exchange para o endpoint de token OIDC. A troca de token pode ser usada para solicitar tokens com escopo menor ou personificar outros usuários. Consulte a política de segurança para permitir a personificação em uma instância.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Projeções de Introspecção com Gatilho OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Habilita gatilhos de projeção durante uma solicitação de introspecção. Isso pode funcionar como uma solução alternativa se houver problemas de consistência perceptíveis na resposta de introspecção, mas pode impactar o desempenho. Planejamos remover gatilhos para solicitações de introspecção no futuro.", "USERSCHEMA": "Esquema de Usuário", "USERSCHEMAS_DESCRIPTION": "Esquemas de Usuário permitem gerenciar esquemas de dados do usuário. Se o sinalizador estiver ativado, você poderá usar a nova API e seus recursos.", "ACTIONS": "Ações", @@ -1645,8 +1641,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "O Logout de Back-Channel implementa o OpenID Connect Back-Channel Logout 1.0 e pode ser usado para notificar os clientes sobre a terminação da sessão no Provedor de OpenID.", "PERMISSIONCHECKV2": "Verificação de Permissão V2", "PERMISSIONCHECKV2_DESCRIPTION": "Se a bandeira estiver ativada, você poderá usar a nova API e seus recursos.", - "WEBKEY": "Chave Web", - "WEBKEY_DESCRIPTION": "Se a bandeira estiver ativada, você poderá usar a nova API e seus recursos.", "STATES": { "INHERITED": "Herdade", "ENABLED": "Habilitado", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index e0f2e93045..a7bf9b4f23 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -1622,12 +1622,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Organizație implicită de conectare", "LOGINDEFAULTORG_DESCRIPTION": "UI-ul de conectare va utiliza setările organizației implicite (și nu din instanță) dacă nu este setat niciun context de organizație", - "OIDCLEGACYINTROSPECTION": "Introspecție OIDC Legacy", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Am refactorizat recent endpointul de introspecție din motive de performanță. Această caracteristică poate fi utilizată pentru a reveni la implementarea legacy dacă apar erori neașteptate.", "OIDCTOKENEXCHANGE": "Schimb de token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Activați tipul de grant experimental urn:ietf:params:oauth:grant-type:token-exchange pentru endpointul token OIDC. Schimbul de tokenuri poate fi utilizat pentru a solicita tokenuri cu o rază de acțiune mai mică sau pentru a impersona alți utilizatori. Consultați politica de securitate pentru a permite impersonarea pe o instanță.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Proiecții de introspecție OIDC Trigger", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Activați declanșatoarele de proiecție în timpul unei solicitări de introspecție. Acest lucru poate acționa ca o soluție dacă există probleme notabile de consistență în răspunsul de introspecție, dar poate avea un impact asupra performanței. Planificăm să eliminăm declanșatoarele pentru solicitările de introspecție în viitor.", "USERSCHEMA": "Schema de utilizator", "USERSCHEMA_DESCRIPTION": "Schemele de utilizator permit gestionarea schemelor de date ale utilizatorului. Dacă indicatorul este activat, veți putea utiliza noul API și caracteristicile sale.", "ACTIONS": "Acțiuni", @@ -1642,8 +1638,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Logout-ul Back-Channel implementează OpenID Connect Back-Channel Logout 1.0 și poate fi folosit pentru a notifica clienții despre terminarea sesiunii la Producătorul OpenID.", "PERMISSIONCHECKV2": "Verificare Permisiuni V2", "PERMISSIONCHECKV2_DESCRIPTION": "Dacă steagul este activat, veți putea folosi noua API și funcțiile sale.", - "WEBKEY": "Cheie Web", - "WEBKEY_DESCRIPTION": "Dacă steagul este activat, veți putea folosi noua API și funcțiile sale.", "STATES": { "INHERITED": "Moșteniți", "ENABLED": "Activat", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 175e18688b..40f35bdcc8 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1677,12 +1677,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Организация по умолчанию для входа", "LOGINDEFAULTORG_DESCRIPTION": "Если контекст организации не установлен, пользовательский интерфейс входа будет использовать настройки организации по умолчанию (а не экземпляра)", - "OIDCLEGACYINTROSPECTION": "Устаревшая интроспекция OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Недавно мы переработали конечную точку интроспекции для повышения производительности. Эта функция может использоваться для отката к устаревшей реализации, если возникнут непредвиденные ошибки.", "OIDCTOKENEXCHANGE": "Обмен токенами OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Включите экспериментальный тип гранта urn:ietf:params:oauth:grant-type:token-exchange для конечной точки токена OIDC. Обмен токенами можно использовать для запроса токенов с меньшей областью действия или для impersonation (выдачи себя за) других пользователей. Информацию о разрешении impersonation на экземпляре см. в политике безопасности.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Проекции интроспекции с триггером OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Включите триггеры проекций во время запроса интроспекции. Это может служить обходным путем, если в ответе интроспекции наблюдаются заметные проблемы согласованности, но может повлиять на производительность. В будущем мы планируем удалить триггеры для запросов интроспекции.", "USERSCHEMA": "Схема пользователя", "USERSCHEMA_DESCRIPTION": "Схемы пользователей позволяют управлять схемами данных пользователей. Если флаг включен, вы сможете использовать новый API и его функции.", "ACTIONS": "Действия", @@ -1697,8 +1693,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout реализует OpenID Connect Back-Channel Logout 1.0 и может использоваться для уведомления клиентов о завершении сеанса у поставщика OpenID.", "PERMISSIONCHECKV2": "Проверка Разрешений V2", "PERMISSIONCHECKV2_DESCRIPTION": "Если флаг включен, вы сможете использовать новый API и его функции.", - "WEBKEY": "Веб-ключ", - "WEBKEY_DESCRIPTION": "Если флаг включен, вы сможете использовать новый API и его функции.", "STATES": { "INHERITED": "Наследовать", "ENABLED": "Включено", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 9de5093353..6dfe81f99c 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1628,12 +1628,8 @@ "FEATURES": { "LOGINDEFAULTORG": "Standardorganisation för inloggning", "LOGINDEFAULTORG_DESCRIPTION": "Inloggningsgränssnittet kommer att använda inställningarna för standardorganisationen (och inte från instansen) om ingen organisationskontext är inställd", - "OIDCLEGACYINTROSPECTION": "OIDC Legacy introspection", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Vi har nyligen omarbetat introspektionsändpunkten av prestandaskäl. Denna funktion kan användas för att återgå till den äldre implementationen om oväntade buggar uppstår.", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktivera den experimentella urn:ietf:params:oauth:grant-type:token-exchange grant-typen för OIDC-tokenändpunkten. Tokenutbyte kan användas för att begära tokens med en mindre omfattning eller impersonera andra användare. Se säkerhetspolicyn för att tillåta impersonation på en instans.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger introspection Projections", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Aktivera projektionstriggers under en introspektionsbegäran. Detta kan fungera som en lösning om det finns märkbara konsistensproblem i introspektionssvaret men kan påverka prestandan. Vi planerar att ta bort triggers för introspektionsbegäranden i framtiden.", "USERSCHEMA": "Användarschema", "USERSCHEMA_DESCRIPTION": "Användarscheman tillåter att hantera datascheman för användare. Om flaggan är aktiverad kommer du att kunna använda det nya API:et och dess funktioner.", "ACTIONS": "Åtgärder", @@ -1648,8 +1644,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout implementerar OpenID Connect Back-Channel Logout 1.0 och kan användas för att meddela klienter om sessionens avslutning hos OpenID-leverantören.", "PERMISSIONCHECKV2": "Behörighetskontroll V2", "PERMISSIONCHECKV2_DESCRIPTION": "Om flaggan är aktiverad kan du använda den nya API:n och dess funktioner.", - "WEBKEY": "Webbnyckel", - "WEBKEY_DESCRIPTION": "Om flaggan är aktiverad kan du använda den nya API:n och dess funktioner.", "STATES": { "INHERITED": "Ärv", "ENABLED": "Aktiverad", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 4aa5ad0ef2..c4b40d71ea 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1624,12 +1624,8 @@ "FEATURES": { "LOGINDEFAULTORG": "登录默认组织", "LOGINDEFAULTORG_DESCRIPTION": "如果没有设置组织上下文,登录界面将使用默认组织的设置(而不是实例的设置)", - "OIDCLEGACYINTROSPECTION": "OIDC 传统内省", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "我们最近出于性能原因重构了内省端点。如果出现意外错误,可以使用此功能回滚到传统实现。", "OIDCTOKENEXCHANGE": "OIDC 令牌交换", "OIDCTOKENEXCHANGE_DESCRIPTION": "启用 OIDC 令牌端点的实验性 urn:ietf:params:oauth:grant-type:token-exchange 授权类型。令牌交换可用于请求具有较少范围的令牌或模拟其他用户。请参阅安全策略以允许在实例上模拟。", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 触发内省投影", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "在内省请求期间启用投影触发器。如果内省响应中存在明显的一致性问题,这可以作为一个解决方法,但可能会影响性能。我们计划在未来删除内省请求的触发器。", "USERSCHEMA": "用户架构", "USERSCHEMA_DESCRIPTION": "用户架构允许管理用户的数据架构。如果启用此标志,您将可以使用新的 API 及其功能。", "ACTIONS": "操作", @@ -1644,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel 注销实现了 OpenID Connect Back-Channel Logout 1.0,可用于通知客户端在 OpenID 提供商处终止会话。", "PERMISSIONCHECKV2": "权限检查 V2", "PERMISSIONCHECKV2_DESCRIPTION": "如果启用该标志,您将能够使用新的 API 及其功能。", - "WEBKEY": "Web 密钥", - "WEBKEY_DESCRIPTION": "如果启用该标志,您将能够使用新的 API 及其功能。", "STATES": { "INHERITED": "继承", "ENABLED": "已启用", diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000000..d75373dee1 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,5 @@ +target "typescript-proto-client" { + contexts = { + proto-files = "target:proto-files" + } +} diff --git a/dockerfiles/proto-files.Dockerfile b/dockerfiles/proto-files.Dockerfile new file mode 100644 index 0000000000..0af3346096 --- /dev/null +++ b/dockerfiles/proto-files.Dockerfile @@ -0,0 +1,8 @@ +FROM bufbuild/buf:1.54.0 AS proto-files +RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto-files && \ + buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --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-files + +FROM scratch +COPY --from=proto-files /proto-files / +COPY ./proto / diff --git a/dockerfiles/proto-files.Dockerfile.dockerignore b/dockerfiles/proto-files.Dockerfile.dockerignore new file mode 100644 index 0000000000..e26cd3c2d6 --- /dev/null +++ b/dockerfiles/proto-files.Dockerfile.dockerignore @@ -0,0 +1,2 @@ +* +!proto diff --git a/dockerfiles/typescript-proto-client.Dockerfile b/dockerfiles/typescript-proto-client.Dockerfile new file mode 100644 index 0000000000..4a9505d19d --- /dev/null +++ b/dockerfiles/typescript-proto-client.Dockerfile @@ -0,0 +1,8 @@ +FROM login-pnpm AS typescript-proto-client +COPY ./login/packages/zitadel-proto/package.json ./packages/zitadel-proto/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter zitadel-proto +COPY --from=proto-files /buf.yaml /buf.lock /proto-files/ +COPY --from=proto-files /zitadel /proto-files/zitadel +COPY ./login/packages/zitadel-proto/buf.gen.yaml ./packages/zitadel-proto/ +RUN cd packages/zitadel-proto && pnpm exec buf generate /proto-files diff --git a/dockerfiles/typescript-proto-client.Dockerfile.dockerignore b/dockerfiles/typescript-proto-client.Dockerfile.dockerignore new file mode 100644 index 0000000000..3915a26e4e --- /dev/null +++ b/dockerfiles/typescript-proto-client.Dockerfile.dockerignore @@ -0,0 +1,11 @@ +* +!/login/packages/zitadel-proto/ +login/packages/zitadel-proto/google +login/packages/zitadel-proto/zitadel +login/packages/zitadel-proto/protoc-gen-openapiv2 +login/packages/zitadel-proto/validate + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/docs/.gitignore b/docs/.gitignore index bd99d98c6f..e894d20ec6 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -27,3 +27,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .vercel +/protoc-gen-connect-openapi* diff --git a/docs/base.yaml b/docs/base.yaml new file mode 100644 index 0000000000..dc5b9aa0f9 --- /dev/null +++ b/docs/base.yaml @@ -0,0 +1,3 @@ +openapi: 3.1.0 +info: + version: v2 \ No newline at end of file diff --git a/docs/buf.gen.yaml b/docs/buf.gen.yaml index a628f6e748..d23040f416 100644 --- a/docs/buf.gen.yaml +++ b/docs/buf.gen.yaml @@ -1,11 +1,18 @@ # buf.gen.yaml -version: v1 +version: v2 managed: enabled: true plugins: - - plugin: buf.build/grpc-ecosystem/openapiv2 + - remote: buf.build/grpc-ecosystem/openapiv2 out: .artifacts/openapi opt: - allow_delete_body - remove_internal_comments=true - preserve_rpc_order=true + - local: ./protoc-gen-connect-openapi/protoc-gen-connect-openapi + out: .artifacts/openapi3 + strategy: all + opt: + - short-service-tags + - ignore-googleapi-http + - base=base.yaml diff --git a/docs/docs/apis/migration_v1_to_v2.mdx b/docs/docs/apis/migration_v1_to_v2.mdx new file mode 100644 index 0000000000..6258b99fd9 --- /dev/null +++ b/docs/docs/apis/migration_v1_to_v2.mdx @@ -0,0 +1,173 @@ +--- +title: Migrate from v1 APIs to v2 APIs +--- + +This guide gives you an overview for migrating from our v1 API to the new and improved v2 API. +This upgrade introduces some significant architectural changes designed to make your development experience smoother, more intuitive, and efficient. + +## The v1 Approach: Use-Case Based APIs +Our v1 API was structured around use cases, meaning we provided distinct APIs tailored to different user roles: + +- Auth API: For authenticated users. +- Management API: For administrators of an organization. +- Admin API: For administrators of an instance. +- System API: For managing multiple instances. + +While this approach served its initial purpose, it presented a few challenges. +Developers often found it difficult to determine which specific API endpoint to use for their needs. +Additionally, this model sometimes led to redundant implementations of similar functionalities across different APIs – for example, listing users might have existed in slightly different forms for an instance context versus an organization context. +This often required more extensive reading of documentation and further explanation to ensure correct usage. + +## The v2 Approach: A Resource-Based API +With our v2 API, we introduce a resource-based architecture. +This means instead of organizing by user type, we now structure our API around logical resources, such as: +- Users API +- Instance API +- Organization API +- And more... + +A key improvement in v2 is how context and permissions are handled. +The data you receive from an endpoint will now automatically be scoped based on the role and permissions of the authenticated user. + +For example: +- An instance administrator calling a GET /users endpoint will receive a list of all users within that instance. +- An organization administrator calling the exact same GET /users endpoint will receive a list of users belonging only to their specific organization. + +## Why the Change +The primary goals behind this architectural shift are to make our API: +- More Intuitive: Finding the right endpoint should feel natural. If you want to interact with users, you look at the Users API. +- Self-Explanatory: The structure itself guides you, reducing the need to sift through extensive documentation to understand which API "hat" you need to wear. +- Developer-Friendly: A cleaner, more consistent API surface means faster integration and less room for confusion. + +We're confident that these changes will significantly enhance your experience working with our platform. +The following sections will detail the specific resources that have been migrated and outline any changes you'll need to be aware of. + +## Resource Migration + +This section details the migrated resources, including any breaking changes and other important considerations for your transition from v1 to v2. + +### General Changes + +#### Sunsetting OpenAPI/REST Support in Favor of Connect RPC +While our v1 API already offered gRPC, it also provided a parallel REST/OpenAPI interface for clients who preferred making traditional HTTP calls. + +In our v2 API, we are consolidating our efforts to provide a more streamlined and efficient development experience. +The primary change is the removal of the OpenAPI/REST interface. +We will now exclusively support interaction with our gRPC services directly or through [Connect RPC](https://connectrpc.com/). + +Connect RPC is being introduced as the new, official way to interact with our gRPC services using familiar, plain HTTP/1.1. +It effectively replaces the previous REST gateway. + +For teams already using gRPC, your transition will be minimal. +For teams who were using the v1 REST API, migrating to v2 will involve adopting one of the following methods: + +- Native gRPC: For the highest performance and to leverage features like bi-directional streaming. +- Connect RPC: For making CRUD-like (Create, Read, Update, Delete) calls over HTTP. This is the recommended path for most clients migrating from our v1 REST API. + +A significant advantage of this new architecture remains the automatic generation of client libraries. +Based on our .proto service definitions, you can generate type-safe clients for your specific programming language, whether you use native gRPC or Connect RPC. +This eliminates the need to write boilerplate code for handling HTTP requests and parsing responses, leading to a more streamlined and less error-prone development process. + +#### Contextual Information in the Request Body + +A key change in v2 is that contextual data, like organization_id, must now be sent in the request body. +Previously, this was sent in the request headers. + +**v1 (Header)** +``` +x-zitadel-orgid: 1234567890 +``` + +**v2 (Request Body)** +``` +{ + "organization_id": "1234567890" +} +``` + +### Instances + +No major changes have been made to the organization requests. + +### Organizations + +No major changes have been made to the organization requests. + +### Users + +When migrating your user management from v1 to v2, the most significant updates involve user states and the initial onboarding process: + +- **Unified User Creation Endpoint**: + - A significant simplification in v2 is the consolidation of user creation. There is now one primary endpoint for creating users, regardless of whether they are human or machine users. + - You can use this single endpoint to provision both human users (individuals interacting with your application) and machine users (e.g., service accounts, API clients), typically by specifying the user type in the request payload. +- **No More "Initial" State**: + - In v1, new users without a password or verified email were automatically assigned an initial state. This default assumption wasn't always ideal. + - In v2, this initial state has been removed. All newly created users are now active by default, regardless of their initial attributes. +- **New Onboarding Process**: + - To enable users to set up their accounts, you can now send them an invitation code. This allows them to securely add their authentication methods. +- **Flexible Email Verification**: + - v2 provides more control over email verification: + - You can choose at user creation whether an email verification code should be sent automatically. + - Alternatively, the API can return the verification code directly to you, empowering you to send a customized verification email. + +[Users API v2 Documentation](/docs/apis/resources/user_service_v2) + +### Projects + +We've simplified how you interact with projects by unifying projects and granted_projects into a single resource. +From a consumer's perspective, it no longer matters if you own a project or if it was granted to you by another organization; it's all just a project. + +The main difference now is your level of permission. +Your permissions determine whether you have administrative rights (like updating the project's details) or if you can only view the project and manage authorizations for your users. + +This change significantly streamlines API calls. +For example, where you previously had to make two separate requests to see all projects, you now make one. + +**v1 (Separate Requests):** +``` +- ListProjects +- ListGrantedProjects +``` + +**v2 (Single Request with Filter):** +``` +- ListProjects (returns all projects you have access to) +``` + +You can now use filters within the single ListProjects request if you need to differentiate between project types, such as filtering by projects you own versus those that have been granted to you. +Update your code to use this new unified ListProjects endpoint. + +### Applications + +We have streamlined the creation and management of applications. +In v1, each application type had its own unique endpoints. +In v2, we have unified these into a single set of endpoints for all application types. + +The biggest change is in how you create/update applications. +Instead of calling a specific endpoint for each type (e.g., CreateOidcApp, CreateSamlApp), you will now use a single CreateApp endpoint. + +To specify the type of application, you will include its specific configuration object within the request body. +For example, to create a OIDC app, you will provide a oidc object in the request. +All properties that are common to every application, such as name, are now top-level fields in the request body, consistent across all types. + +This approach simplifies client-side logic, as you no longer need to route requests to different endpoints. + +**v1 (Multiple, Type-Specific Endpoints):** + +``` +- AddOIDCApp +- AddSAMLApp +- AddAPIApp +``` + +**v2 (Single Endpoint with Type-Specific Body):** + +``` +- CreateApplication + - ProjectID + - Name + - Type + - OIDC + - SAML + - API +``` \ No newline at end of file diff --git a/docs/docs/apis/openidoauth/scopes.md b/docs/docs/apis/openidoauth/scopes.md index 86f9769cab..d1fe9c7c5b 100644 --- a/docs/docs/apis/openidoauth/scopes.md +++ b/docs/docs/apis/openidoauth/scopes.md @@ -8,7 +8,7 @@ ZITADEL supports the usage of scopes as way of requesting information from the I ## Standard Scopes | Scopes | Description | -|:---------------|--------------------------------------------------------------------------------| +| :------------- | ------------------------------------------------------------------------------ | | openid | When using openid connect this is a mandatory scope | | profile | Optional scope to request the profile of the subject | | email | Optional scope to request the email of the subject | @@ -30,11 +30,9 @@ In addition to the standard compliant scopes we utilize the following scopes. | `urn:zitadel:iam:org:projects:roles` | `urn:zitadel:iam:org:projects:roles` | By using this scope a client can request the claim `urn:zitadel:iam:org:project:{projectid}:roles` to be asserted for each requested project. All projects of the token audience, requested by the `urn:zitadel:iam:org:project:id:{projectid}:aud` scopes will be used. | | `urn:zitadel:iam:org:id:{id}` | `urn:zitadel:iam:org:id:178204173316174381` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization. If the organization does not exist a failure is displayed. It will assert the `urn:zitadel:iam:user:resourceowner` claims. | | `urn:zitadel:iam:org:domain:primary:{domainname}` | `urn:zitadel:iam:org:domain:primary:acme.ch` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization and the username is suffixed by the provided domain. If the organization does not exist a failure is displayed | -| `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles.[^1] | +| `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles. | | `urn:zitadel:iam:org:project:id:{projectid}:aud` | `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested projectid will be added to the audience of the access token | | `urn:zitadel:iam:org:project:id:zitadel:aud` | `urn:zitadel:iam:org:project:id:zitadel:aud` | By adding this scope, the ZITADEL project ID will be added to the audience of the access token | | `urn:zitadel:iam: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 resource owner (the users 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. | - -[^1]: `urn:zitadel:iam:org:roles:id:{orgID}` is not supported when the `oidcLegacyIntrospection` [feature flag](/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) is enabled. diff --git a/docs/docs/guides/integrate/login/oidc/webkeys.md b/docs/docs/guides/integrate/login/oidc/webkeys.md index 62f62a90e0..288284fefc 100644 --- a/docs/docs/guides/integrate/login/oidc/webkeys.md +++ b/docs/docs/guides/integrate/login/oidc/webkeys.md @@ -20,13 +20,6 @@ JWT access tokens, instead of [introspection](/docs/apis/openidoauth/endpoints#i ZITADEL uses public key verification when API calls are made or when the userInfo or introspection endpoints are called with a JWT access token. -:::info -Web keys are an [experimental](/docs/support/software-release-cycles-support#beta) feature. Be sure to enable the `web_key` [feature](/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) before using it. - -The documentation describes the state of the feature in ZITADEL V3. -Test the feature and add improvement or bug reports directly to the [github repository](https://github.com/zitadel/zitadel) or let us know your general feedback in the [discord thread](https://discord.com/channels/927474939156643850/1329100936127320175/threads/1332344892629717075)! -::: - ### JSON Web Key ZITADEL implements the [RFC7517 - JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517) format for storage and distribution of public keys. diff --git a/docs/docs/guides/migrate/sources/auth0-guide.md b/docs/docs/guides/migrate/sources/auth0-guide.md new file mode 100644 index 0000000000..f8a62b75fa --- /dev/null +++ b/docs/docs/guides/migrate/sources/auth0-guide.md @@ -0,0 +1,185 @@ +--- +title: Migrating Users from Auth0 to ZITADEL (Including Password Hashes) +sidebar_label: Auth0 Migration Guide +--- + +## 1. Introduction + +This guide will walk you through the steps to migrate users from Auth0 to ZITADEL, including password hashes (which requires Auth0's support assistance), so users don't need to reset their passwords. + +**What you'll learn with this guide** +- How to prepare your data from Auth0 +- Use of the ZITADEL migration tooling +- Performing the user import via ZITADEL's API +- Troubleshooting and validating the migration + +--- + +## 2. Prerequisites + +### 2.1. Install Go +The migration tool is written in Go. Download and install the latest version of Go from the [official Go website](https://go.dev/doc/install). + +### 2.2. Create a ZITADEL Instance and Organization +You'll need a target organization in ZITADEL to import your users. You can create a new organization or use an existing one. + +If you don't have a ZITADEL instance, you can [sign up for free here](https://zitadel.com) to create a new one for you. +See: [Managing Organizations in ZITADEL](https://zitadel.com/docs/guides/manage/console/organizations). + +> **Note:** Copy your Organization ID (Resource ID) since you will use the id in the later steps. + +--- + +## 3. Preparing Auth0 Data + +### 3.1. Export User Profiles and Password Hashes from Auth0 +You cannot bulk export user data from the Auth0 Dashboard. Instead, use the [Auth0 Management API](https://auth0.com/docs/manage-users/user-migration#bulk-user-exports) or the [User Import/Export extension](https://auth0.com/docs/manage-users/user-migration/user-import-export-extension). + +> **Important:** Password hashes cannot be obtained in a self-service way. +> You must open a **support ticket** with Auth0 and request a password hash export. +> If approved, Auth0 will provide an export containing the password hashes. + +Reference: [Export hashed passwords from Auth0](https://zitadel.com/docs/guides/migrate/sources/auth0#export-hashed-passwords) + +--- + +## 4. Running the ZITADEL Migration Tool + +### 4.1. Install the Migration Tool +Follow the installation instructions to set up the ZITADEL migration tool from [ZITADEL Tools](https://github.com/zitadel/zitadel-tools?tab=readme-ov-file#installation). + +### 4.2. Generate Import JSON +Use the migration tool to convert the Auth0 export file to a ZITADEL-compatible JSON. +Step-by-step instructions: [Migration Tool for Auth0](https://github.com/zitadel/zitadel-tools/blob/main/cmd/migration/auth0/readme.md) + +Typical steps: +- Run the migration tool with your exported Auth0 files as input. +- The tool generates a JSON file ready for import into ZITADEL. + +Example: +After obtaining the 2 required input files (passwords and profile) in JSON lines format, you can run the following command: + +Sample `passwords.ndjson` content, as obtained from the Auth0 Support team: +```json +{"_id":{"$oid":"emxdpVxozXeFb1HeEn5ThAK8"},"email_verified":true,"email":"tommie_krajcik85@hotmail.com","passwordHash":"$2b$10$d.GvZhGwTllA7OdAmsA75uGGzqr/mhdQoU88M3zD.fX3Vb8Rcf33.","password_set_date":{"$date":"2025-06-30T00:00:00.000Z"},"tenant":"test","connection":"Username-Password-Authentication","_tmp_is_unique":true} +``` + +Sample `profiles.json` content, as obtained from the Auth0 Management API: +```json +{"user_id":"auth0|emxdpVxozXeFb1HeEn5ThAK8","email_verified":true,"name":"Tommie Krajcik","email":"tommie_krajcik85@hotmail.com"} +``` + +Run the following command in your terminal (replace ORG_ID with your own organization ID): +```bash +zitadel-tools migrate auth0 --org= --users=./profiles.json --passwords=./passwords.ndjson --multiline --email-verified --output=./importBody.json --timeout=5m0s +``` + +The tool will merge both objects into a single one in the importBody.json output, this will be used in the next step to complete the import process. + +## 5. Importing Users into ZITADEL + +### 5.1. Obtain Access Token (or PAT) for API Access + +To call the ZITADEL Admin API, you need to authenticate using a **Service User** with the `IAM_OWNER` Manager permissions. + +There are two recommended authentication methods: + +- **Client Credentials Flow** + [Learn how to authenticate with client credentials.](https://zitadel.com/docs/guides/integrate/service-users/client-credentials) + +- **Personal Access Token (PAT)** + [Learn how to create and use a PAT.](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users#personal-access-token) + +**Reference:** [Service Users & API Authentication](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users#authentication-methods) + +--- + +### 5.2. Import Data with the ZITADEL API + +- Use your **access token** or **PAT** to authenticate. +- Call the [Admin API – Import Data](https://zitadel.com/docs/apis/resources/admin/admin-service-import-data) endpoint, passing your generated JSON file. +- Verify that the users were imported successfully in the ZITADEL console. + +**Import Endpoint:** + +- `POST /admin/v1/import` +- `Authorization: Bearer ` +- **Body:** Generated in step 4.2 + +#### Example cURL request + +```bash +curl --location 'https:///admin/v1/import' \ +--header 'Content-Type: application/json' \ +--header 'Accept: application/json' \ +--header 'Authorization: Bearer ' \ +--data-raw '{ + "dataOrgs": { + "orgs": [ + { + "orgId": "", + "humanUsers": [ + { + "userId": "auth0|emxdpVxozXeFb1HeEn5ThAK8", + "user": { + "userName": "tommie_krajcik85@hotmail.com", + "profile": { + "firstName": "Tommie Krajcik", + "lastName": "Tommie Krajcik" + }, + "email": { + "email": "tommie_krajcik85@hotmail.com", + "isEmailVerified": true + }, + "hashedPassword": { + "value": "$2b$10$d.GvZhGwTllA7OdAmsA75uGGzqr/mhdQoU88M3zD.fX3Vb8Rcf33." + } + } + } + ] + } + ] + }, + "timeout": "5m0s" +}' +``` + +## 6. Testing the Migration + +### 6.1. Test User Login + +Use the **ZITADEL login page** or your integrated app to test logging in with one of the imported users. + +> **Password for the sample user:** `Password1!` + +Confirm that the migrated password works as expected. + +--- + +### 6.2. Troubleshooting + +**Common issues:** + +- Missing password hashes +- Malformed JSON +- Invalid or incomplete user data + +The import endpoint returns an `errors` array which can help you identify any issues with the import. + +#### Where to check logs and get help + +You can also verify that a user was imported by calling the **events endpoint** and checking for the following event type: + +```json +"user.human.added" +``` + +## 7. Q&A and Further Resources + +### Real-World Scenarios & Common Questions + +**Q:** What is the maximum number of users that can be imported in a single batch? +**A:** There is no hard limit on the number of users. However, there is a **timeout**. +For **ZITADEL Cloud deployments**, the timeout is **5 minutes**, which typically allows for importing around **5,000 users per batch**. + +--- \ No newline at end of file diff --git a/docs/docs/legal/policies/account-lockout-policy.md b/docs/docs/legal/policies/account-lockout-policy.md index a593eac1bc..663fd12d9d 100644 --- a/docs/docs/legal/policies/account-lockout-policy.md +++ b/docs/docs/legal/policies/account-lockout-policy.md @@ -4,56 +4,69 @@ sidebar_label: Account Lockout Policy custom_edit_url: null --- -Last updated on May 31, 2023 +Last updated on June 25, 2025 -This policy is an annex to the [Terms of Service](../terms-of-service) that clarifies your obligations and our procedure handling requests where you can't get access to your ZITADEL Cloud services and data. This policy is applicable to situations where we, ZITADEL, need to restore your access for a otherwise available service and not in cases where the services are unavailable. +This policy is an annex to the [Terms of Service](../terms-of-service) and outlines your responsibilities, as well as our procedures, for handling situations where you are unable to access your ZITADEL Cloud services or data. -## Why to do we have this policy? +It applies specifically to cases where **ZITADEL** must restore your access to services that are otherwise operational, and does **not** cover service outages or unavailability. -Users may not be able to access our services anymore due to loss of credentials or misconfiguration. -In certain circumstances it might not be possible to recover the credentials through a self-service flow (eg, loss of 2FA credentials) or access the system to undo the configuration that caused the issue. -These cases might require help from our support, so you can regain access to your data. -We will require some initial information and conditions to be able to assist you, and will require further information to handle the request. -We also keep the right to refuse any such request without providing a reason, in case you can't provide the requested information. +## Why do we have this policy? -## Scope +Users may lose access to ZITADEL services due to lost credentials or misconfiguration. -In scope of this policy are requests to recover +In some cases, it may not be possible to recover access through self-service options—for example, losing access to 2FA credentials or being unable to reverse a misconfiguration. These situations may require support from our team to help you regain access to your data. -- ZITADEL Cloud account (customer portal) -- Manager accounts to a specific instance -- Undo configuration changes resulting in lockout (eg, misconfigured Action) +To assist with such requests, we will require specific information and may request additional details throughout the process. -Out of scope are requests to recover access +**ZITADEL reserves the right to decline any access recovery request without providing a reason if the required information cannot be verified or provided.** + + +## Scope of This Policy + +This policy applies to the following situations: + +- Loss of access to your **ZITADEL Cloud Admin Account** (customer portal) +- Inability to access **Instance Manager accounts** for a specific instance +- Need to **undo configuration changes** that caused a lockout (e.g., a misconfigured Action) + + +## Out of Scope + +The following types of access recovery requests are **not** covered by this policy: + +- Situations where you can request access from another **Admin** or **Instance Manager** +- Requests made by **end-users** who should instead contact their Admin or Manager +- Issues related to **self-hosted ZITADEL instances** +- **Free accounts/Instances** -- Where you have to option to ask another Admin/Manager -- by end-users who should ask an Admin/Manager instead -- self-hosted instances ## Process -Before you send a request to restore access to your account, please make sure that can't ask your manager/admin or another manager/admin to recover access. +Before submitting a request to restore access to your account, please ensure that you are unable to regain access through your existing **Manager** or **Admin**, or by contacting another **Manager/Admin** within your organization. -### ZITADEL Cloud account -If you need to recover your ZITADEL Cloud account for the customer portal, please send an email to [support@zitadel.com](mailto:support@zitadel.com?subject=ZITADEL%20Cloud%20account%20lockout): +### ZITADEL Cloud account (Customer Portal) + +Please visit the [support page in the customer portal](https://zitadel.com/admin/support): - State clearly in the subject line that this is related to an account lockout for a ZITADEL Cloud account - The sender's email address must match the verified email address of the account owner - State the reason why you're not able to recover the account yourself -Please allow us time to validate your request. -Our support will get back to you to request additional information for verification. +Please allow us time to validate your request. +Our support team will follow up with additional verification steps if needed. -### Manager access to an Instance +### Instance Manager access recovery If you need to recover a Manager account to an instance, please make sure you can't recover the account via another user or service user with Manager permissions. -Please visit the [support page in the customer portal](https://zitadel.cloud/admin/support): +Please visit the [support page in the customer portal](https://zitadel.com/admin/support): -- State clearly in the subject line that this is related to an account lockout the affected instance +- State clearly in the subject line that this is related to an account lockout **for** the affected instance +- The sender's email address must match the verified email address of the affected instance manager - State the reason why you're not able to recover the account yourself -Please allow us time to validate your request. -Our support will get back to you to request additional information for verification. +Please allow us time to validate your request. +Our support team will follow up with additional verification steps if needed. + diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore b/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore index bd98bacd66..8a28618b17 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore @@ -1 +1 @@ -.env-file +.env-file \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml index 013fc2aa22..96a87fa8d7 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml @@ -41,17 +41,17 @@ services: 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 + - -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: @@ -154,4 +154,4 @@ networks: backend: volumes: - data: + data: \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml index fadd39373d..af5bb5145c 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml @@ -26,4 +26,4 @@ SAML.DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_SAML_DEFAULT LogStore.Access.Stdout.Enabled: true # Skipping the MFA init step allows us to immediately authenticate at the console -DefaultInstance.LoginPolicy.MfaInitSkipLifetime: "0s" +DefaultInstance.LoginPolicy.MfaInitSkipLifetime: "0s" \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml index be63164ced..9bdf41269d 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml @@ -9,4 +9,4 @@ FirstInstance: Machine: Username: 'login-container' Name: 'Login Container' - Pat.ExpirationDate: '2029-01-01T00:00:00Z' + Pat.ExpirationDate: '2029-01-01T00:00:00Z' \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx index d4c27ccd95..3fb4784ea0 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx @@ -71,4 +71,4 @@ Open your favorite internet browser at https://127.0.0.1.sslip.io/ui/console?log 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). +Read more about [the login process](/guides/integrate/login/oidc/login-users). \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/macos.mdx b/docs/docs/self-hosting/deploy/macos.mdx index beb3182208..aea5fb07e9 100644 --- a/docs/docs/self-hosting/deploy/macos.mdx +++ b/docs/docs/self-hosting/deploy/macos.mdx @@ -64,4 +64,4 @@ mv /tmp/zitadel-admin-sa.json $HOME/zitadel-admin-sa.json This key can be used to provision resources with for example [Terraform](/docs/guides/manage/terraform-provider). - + \ No newline at end of file diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 43830eafd0..3ca8247a57 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -71,13 +71,13 @@ module.exports = { label: "🚀 Quick Start", docId: "guides/start/quickstart", position: "left", - }, + }, { type: "doc", label: "Documentation", docId: "guides/overview", position: "left", - }, + }, { type: "doc", label: "APIs", @@ -174,20 +174,25 @@ module.exports = { { label: "Status", href: "https://status.zitadel.com/", - } + }, ], }, ], copyright: `Copyright © ${new Date().getFullYear()} ZITADEL Docs - Built with Docusaurus.`, }, - algolia: { - appId: "8H6ZKXENLO", - apiKey: "124fe1c102a184bc6fc70c75dc84f96f", - indexName: "zitadel", - selector: "div#", - }, prism: { - additionalLanguages: ["csharp", "dart", "groovy", "regex", "java", "php", "python", "protobuf", "json", "bash"], + additionalLanguages: [ + "csharp", + "dart", + "groovy", + "regex", + "java", + "php", + "python", + "protobuf", + "json", + "bash", + ], }, colorMode: { defaultMode: "dark", @@ -196,9 +201,9 @@ module.exports = { }, codeblock: { showGithubLink: true, - githubLinkLabel: 'View on GitHub', + githubLinkLabel: "View on GitHub", showRunmeLink: false, - runmeLinkLabel: 'Checkout via Runme' + runmeLinkLabel: "Checkout via Runme", }, }, presets: [ @@ -213,19 +218,43 @@ module.exports = { showLastUpdateTime: true, editUrl: "https://github.com/zitadel/zitadel/edit/main/docs/", remarkPlugins: [require("mdx-mermaid")], - - docItemComponent: '@theme/ApiItem' + docItemComponent: "@theme/ApiItem", }, theme: { customCss: require.resolve("./src/css/custom.css"), }, - }) + }), ], - ], plugins: [ [ - 'docusaurus-plugin-openapi-docs', + "@inkeep/cxkit-docusaurus", + { + SearchBar: { + baseSettings: { + apiKey: process.env.INKEEP_API_KEY, + primaryBrandColor: "#ff2069", + organizationDisplayName: "ZITADEL", + }, + }, + SearchSettings: { + tabs: ["All", "Docs", "GitHub", "Forums", "Discord"], + }, + }, + ], + [ + "@signalwire/docusaurus-plugin-llms-txt", + { + depth: 3, + logLevel: 1, + content: { + excludeRoutes: ["/search"], + enableMarkdownFiles: true, + }, + }, + ], + [ + "docusaurus-plugin-openapi-docs", { id: "apiDocs", docsPluginId: "classic", @@ -263,7 +292,8 @@ module.exports = { }, }, user_v2: { - specPath: ".artifacts/openapi/zitadel/user/v2/user_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/user/v2/user_service.swagger.json", outputDir: "docs/apis/resources/user_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -271,7 +301,8 @@ module.exports = { }, }, session_v2: { - specPath: ".artifacts/openapi/zitadel/session/v2/session_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/session/v2/session_service.swagger.json", outputDir: "docs/apis/resources/session_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -279,7 +310,8 @@ module.exports = { }, }, oidc_v2: { - specPath: ".artifacts/openapi/zitadel/oidc/v2/oidc_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/oidc/v2/oidc_service.swagger.json", outputDir: "docs/apis/resources/oidc_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -287,7 +319,8 @@ module.exports = { }, }, saml_v2: { - specPath: ".artifacts/openapi/zitadel/saml/v2/saml_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/saml/v2/saml_service.swagger.json", outputDir: "docs/apis/resources/saml_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -295,7 +328,8 @@ module.exports = { }, }, settings_v2: { - specPath: ".artifacts/openapi/zitadel/settings/v2/settings_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/settings/v2/settings_service.swagger.json", outputDir: "docs/apis/resources/settings_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -303,31 +337,35 @@ module.exports = { }, }, action_v2: { - specPath: ".artifacts/openapi/zitadel/action/v2beta/action_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/action/v2beta/action_service.swagger.json", outputDir: "docs/apis/resources/action_service_v2", sidebarOptions: { - groupPathsBy: "tag", - categoryLinkSource: "auto", + groupPathsBy: "tag", + categoryLinkSource: "auto", }, }, webkey_v2: { - specPath: ".artifacts/openapi/zitadel/webkey/v2beta/webkey_service.swagger.json", + specPath: + ".artifacts/openapi3/zitadel/webkey/v2/webkey_service.openapi.yaml", outputDir: "docs/apis/resources/webkey_service_v2", sidebarOptions: { - groupPathsBy: "tag", - categoryLinkSource: "auto", + groupPathsBy: "tag", + categoryLinkSource: "auto", }, }, feature_v2: { - specPath: ".artifacts/openapi/zitadel/feature/v2/feature_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/feature/v2/feature_service.swagger.json", outputDir: "docs/apis/resources/feature_service_v2", sidebarOptions: { - groupPathsBy: "tag", - categoryLinkSource: "auto", + groupPathsBy: "tag", + categoryLinkSource: "auto", }, }, org_v2: { - specPath: ".artifacts/openapi/zitadel/org/v2/org_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/org/v2/org_service.swagger.json", outputDir: "docs/apis/resources/org_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -335,7 +373,8 @@ module.exports = { }, }, idp_v2: { - specPath: ".artifacts/openapi/zitadel/idp/v2/idp_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/idp/v2/idp_service.swagger.json", outputDir: "docs/apis/resources/idp_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -343,7 +382,8 @@ module.exports = { }, }, org_v2beta: { - specPath: ".artifacts/openapi/zitadel/org/v2beta/org_service.swagger.json", + specPath: + ".artifacts/openapi3/zitadel/org/v2beta/org_service.openapi.yaml", outputDir: "docs/apis/resources/org_service_v2beta", sidebarOptions: { groupPathsBy: "tag", @@ -351,21 +391,49 @@ module.exports = { }, }, project_v2beta: { - specPath: ".artifacts/openapi/zitadel/project/v2beta/project_service.swagger.json", + specPath: + ".artifacts/openapi3/zitadel/project/v2beta/project_service.openapi.yaml", outputDir: "docs/apis/resources/project_service_v2", sidebarOptions: { groupPathsBy: "tag", categoryLinkSource: "auto", }, }, + application_v2: { + specPath: ".artifacts/openapi3/zitadel/app/v2beta/app_service.openapi.yaml", + outputDir: "docs/apis/resources/application_service_v2", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, instance_v2: { - specPath: ".artifacts/openapi/zitadel/instance/v2beta/instance_service.swagger.json", + specPath: + ".artifacts/openapi3/zitadel/instance/v2beta/instance_service.openapi.yaml", outputDir: "docs/apis/resources/instance_service_v2", sidebarOptions: { groupPathsBy: "tag", categoryLinkSource: "auto", }, }, + authorization_v2: { + specPath: + ".artifacts/openapi3/zitadel/authorization/v2beta/authorization_service.openapi.yaml", + outputDir: "docs/apis/resources/authorization_service_v2", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, + internal_permission_v2: { + specPath: + ".artifacts/openapi3/zitadel/internal_permission/v2beta/internal_permission_service.openapi.yaml", + outputDir: "docs/apis/resources/internal_permission_service_v2", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, }, }, ], @@ -382,13 +450,16 @@ module.exports = { }; }, ], - themes: [ "docusaurus-theme-github-codeblock", "docusaurus-theme-openapi-docs"], + themes: [ + "docusaurus-theme-github-codeblock", + "docusaurus-theme-openapi-docs", + ], future: { v4: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040 experimental_faster: { swcJsLoader: false, // Disabled because of memory usage > 8GB which is a problem on vercel default runners swcJsMinimizer: true, - swcHtmlMinimizer : true, + swcHtmlMinimizer: true, lightningCssMinimizer: true, mdxCrossCompilerCache: true, ssgWorkerThreads: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040 diff --git a/docs/package.json b/docs/package.json index 2c9eb8bb84..c1b23cecb1 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,8 @@ "generate:apidocs": "docusaurus gen-api-docs all", "generate:configdocs": "cp -r ../cmd/defaults.yaml ./docs/self-hosting/manage/configure/ && cp -r ../cmd/setup/steps.yaml ./docs/self-hosting/manage/configure/", "generate:re-gen": "yarn generate:clean-all && yarn generate", - "generate:clean-all": "docusaurus clean-api-docs all" + "generate:clean-all": "docusaurus clean-api-docs all", + "postinstall": "sh ./plugin-download.sh" }, "dependencies": { "@bufbuild/buf": "^1.14.0", @@ -29,6 +30,8 @@ "@docusaurus/theme-search-algolia": "^3.8.1", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", + "@signalwire/docusaurus-plugin-llms-txt": "^1.2.0", + "@inkeep/cxkit-docusaurus": "^0.5.89", "autoprefixer": "^10.4.13", "clsx": "^1.2.1", "docusaurus-plugin-image-zoom": "^3.0.1", diff --git a/docs/plugin-download.sh b/docs/plugin-download.sh new file mode 100644 index 0000000000..499326a1e7 --- /dev/null +++ b/docs/plugin-download.sh @@ -0,0 +1,22 @@ +echo $(uname -m) +mkdir protoc-gen-connect-openapi +cd ./protoc-gen-connect-openapi/ +if [ "$(uname)" = "Darwin" ]; then + curl -L -o protoc-gen-connect-openapi.tar.gz https://github.com/sudorandom/protoc-gen-connect-openapi/releases/download/v0.18.0/protoc-gen-connect-openapi_0.18.0_darwin_all.tar.gz +else + ARCH=$(uname -m) + case $ARCH in + x86_64) + ARCH="amd64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; + esac + curl -L -o protoc-gen-connect-openapi.tar.gz https://github.com/sudorandom/protoc-gen-connect-openapi/releases/download/v0.18.0/protoc-gen-connect-openapi_0.18.0_linux_${ARCH}.tar.gz +fi +tar -xvf protoc-gen-connect-openapi.tar.gz \ No newline at end of file diff --git a/docs/sidebars.js b/docs/sidebars.js index d7ebb80f5b..bc75f9ed87 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -16,6 +16,9 @@ const sidebar_api_actions_v2 = require("./docs/apis/resources/action_service_v2/ const sidebar_api_project_service_v2 = require("./docs/apis/resources/project_service_v2/sidebar.ts").default const sidebar_api_webkey_service_v2 = require("./docs/apis/resources/webkey_service_v2/sidebar.ts").default const sidebar_api_instance_service_v2 = require("./docs/apis/resources/instance_service_v2/sidebar.ts").default +const sidebar_api_authorization_service_v2 = require("./docs/apis/resources/authorization_service_v2/sidebar.ts").default +const sidebar_api_permission_service_v2 = require("./docs/apis/resources/internal_permission_service_v2/sidebar.ts").default +const sidebar_api_app_v2 = require("./docs/apis/resources/application_service_v2/sidebar.ts").default module.exports = { guides: [ @@ -203,6 +206,7 @@ module.exports = { items: [ "guides/migrate/sources/zitadel", "guides/migrate/sources/auth0", + "guides/migrate/sources/auth0-guide", "guides/migrate/sources/keycloak", ], }, @@ -660,6 +664,228 @@ module.exports = { id: "apis/apis/index", }, items: [ + + { + type: "category", + label: "V2", + collapsed: false, + link: { + type: "doc", + id: "apis/v2", + }, + items: [ + { + type: "category", + label: "User", + link: { + type: "generated-index", + title: "User Service API", + slug: "/apis/resources/user_service_v2", + description: + "This API is intended to manage users in a ZITADEL instance.\n", + }, + items: sidebar_api_user_service_v2, + }, + { + type: "category", + label: "Session", + link: { + type: "generated-index", + title: "Session Service API", + slug: "/apis/resources/session_service_v2", + description: + "This API is intended to manage sessions in a ZITADEL instance.\n", + }, + items: sidebar_api_session_service_v2, + }, + { + type: "category", + label: "OIDC", + link: { + type: "generated-index", + title: "OIDC Service API", + slug: "/apis/resources/oidc_service_v2", + description: + "Get OIDC Auth Request details and create callback URLs.\n", + }, + items: sidebar_api_oidc_service_v2, + }, + { + type: "category", + label: "Settings", + link: { + type: "generated-index", + title: "Settings Service API", + slug: "/apis/resources/settings_service_v2", + description: + "This API is intended to manage settings in a ZITADEL instance.\n", + }, + items: sidebar_api_settings_service_v2, + }, + { + type: "category", + label: "Feature", + link: { + type: "generated-index", + title: "Feature Service API", + slug: "/apis/resources/feature_service_v2", + description: + 'This API is intended to manage features for ZITADEL. Feature settings that are available on multiple "levels", such as instance and organization. The higher level instance acts as a default for the lower level. When a feature is set on multiple levels, the lower level takes precedence. Features can be experimental where ZITADEL will assume a sane default, such as disabled. When over time confidence in such a feature grows, ZITADEL can default to enabling the feature. As a final step we might choose to always enable a feature and remove the setting from this API, reserving the proto field number. Such removal is not considered a breaking change. Setting a removed field will effectively result in a no-op.\n', + }, + items: sidebar_api_feature_service_v2, + }, + { + type: "category", + label: "Organization", + link: { + type: "generated-index", + title: "Organization Service API", + slug: "/apis/resources/org_service/v2", + description: + "This API is intended to manage organizations for ZITADEL. \n", + }, + items: sidebar_api_org_service_v2, + }, + { + type: "category", + label: "Organization (Beta)", + link: { + type: "generated-index", + title: "Organization Service beta API", + slug: "/apis/resources/org_service/v2beta", + description: + "This API is intended to manage organizations for ZITADEL. \n", + }, + items: sidebar_api_org_service_v2beta, + }, + { + type: "category", + label: "Identity Provider", + link: { + type: "generated-index", + title: "Identity Provider Service API", + slug: "/apis/resources/idp_service_v2", + description: + "This API is intended to manage identity providers (IdPs) for ZITADEL.\n", + }, + items: sidebar_api_idp_service_v2, + }, + { + type: "category", + label: "Web Key", + link: { + type: "generated-index", + title: "Web Key Service API", + slug: "/apis/resources/webkey_service_v2", + description: + "This API is intended to manage web keys for a ZITADEL instance, used to sign and validate OIDC tokens.\n" + + "\n"+ + "The public key endpoint (outside of this service) is used to retrieve the public keys of the active and inactive keys.\n", + }, + items: sidebar_api_webkey_service_v2 + }, + { + type: "category", + label: "Action (Beta)", + link: { + type: "generated-index", + title: "Action Service API (Beta)", + slug: "/apis/resources/action_service_v2", + description: + "This API is intended to manage custom executions and targets (previously known as actions) in a ZITADEL instance.\n" + + "\n" + + "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ + "\n" + + "The version 2 of actions provide much more options to customize ZITADELs behaviour than previous action versions.\n" + + "Also, v2 actions are available instance-wide, whereas previous actions had to be managed for each organization individually\n" + + "ZITADEL doesn't restrict the implementation languages, tooling and runtime for v2 action executions anymore.\n" + + "Instead, it calls external endpoints which are implemented and maintained by action v2 users.\n"+ + "\n" + + "Please make sure to enable the `actions` feature flag on your instance to use this service and that you're running Zitadel V3.", + }, + items: sidebar_api_actions_v2, + }, + { + type: "category", + label: "Instance (Beta)", + link: { + type: "generated-index", + title: "Instance Service API (Beta)", + slug: "/apis/resources/instance_service_v2", + description: + "This API is intended to manage instances, custom domains and trusted domains in ZITADEL.\n" + + "\n" + + "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ + "\n" + + "This v2 of the API provides the same functionalities as the v1, but organised on a per resource basis.\n" + + "The whole functionality related to domains (custom and trusted) has been moved under this instance API." + , + }, + items: sidebar_api_instance_service_v2, + }, + { + type: "category", + label: "Project (Beta)", + link: { + type: "generated-index", + title: "Project Service API (Beta)", + slug: "/apis/resources/project_service_v2", + description: + "This API is intended to manage projects and subresources for ZITADEL. \n" + + "\n" + + "This service is in beta state. It can AND will continue breaking until a stable version is released.", + }, + items: sidebar_api_project_service_v2, + }, + { + type: "category", + label: "App (Beta)", + link: { + type: "generated-index", + title: "Application Service API (Beta)", + slug: "/apis/resources/application_service_v2", + description: + "This API lets you manage Zitadel applications (API, SAML, OIDC).\n"+ + "\n"+ + "The API offers generic endpoints that work for all app types (API, SAML, OIDC), "+ + "\n"+ + "This API is in beta state. It can AND will continue breaking until a stable version is released.\n" + }, + items: sidebar_api_app_v2, + }, + { + type: "category", + label: "Authorizations (Beta)", + link: { + type: "generated-index", + title: "Authorization Service API (Beta)", + slug: "/apis/resources/authorization_service_v2", + description: + "AuthorizationService provides methods to manage authorizations for users within your projects and applications.\n" + + "\n" + + "For managing permissions and roles for ZITADEL internal resources, like organizations, projects,\n" + + "users, etc., please use the InternalPermissionService."+ + "\n"+ + "This API is in beta state. It can AND will continue breaking until a stable version is released.\n" + }, + items: sidebar_api_authorization_service_v2, + }, + { + type: "category", + label: "Permissions (Beta)", + link: { + type: "generated-index", + title: "Permission Service API (Beta)", + slug: "/apis/resources/permission_service_v2", + description: + "This API is intended to manage internal permissions in ZITADEL.\n" + + "\n"+ + "This API is in beta state. It can AND will continue breaking until a stable version is released.\n" + }, + items: sidebar_api_permission_service_v2, + }, + ], + }, { type: "category", label: "V1", @@ -722,140 +948,7 @@ module.exports = { }, items: sidebar_api_system, }, - ], - }, - { - type: "category", - label: "V2", - collapsed: false, - link: { - type: "doc", - id: "apis/v2", - }, - items: [ - { - type: "category", - label: "User", - link: { - type: "generated-index", - title: "User Service API", - slug: "/apis/resources/user_service_v2", - description: - "This API is intended to manage users in a ZITADEL instance.\n", - }, - items: sidebar_api_user_service_v2, - }, - { - type: "category", - label: "Session", - link: { - type: "generated-index", - title: "Session Service API", - slug: "/apis/resources/session_service_v2", - description: - "This API is intended to manage sessions in a ZITADEL instance.\n", - }, - items: sidebar_api_session_service_v2, - }, - { - type: "category", - label: "OIDC", - link: { - type: "generated-index", - title: "OIDC Service API", - slug: "/apis/resources/oidc_service_v2", - description: - "Get OIDC Auth Request details and create callback URLs.\n", - }, - items: sidebar_api_oidc_service_v2, - }, - { - type: "category", - label: "Settings", - link: { - type: "generated-index", - title: "Settings Service API", - slug: "/apis/resources/settings_service_v2", - description: - "This API is intended to manage settings in a ZITADEL instance.\n", - }, - items: sidebar_api_settings_service_v2, - }, - { - type: "category", - label: "Feature", - link: { - type: "generated-index", - title: "Feature Service API", - slug: "/apis/resources/feature_service_v2", - description: - 'This API is intended to manage features for ZITADEL. Feature settings that are available on multiple "levels", such as instance and organization. The higher level instance acts as a default for the lower level. When a feature is set on multiple levels, the lower level takes precedence. Features can be experimental where ZITADEL will assume a sane default, such as disabled. When over time confidence in such a feature grows, ZITADEL can default to enabling the feature. As a final step we might choose to always enable a feature and remove the setting from this API, reserving the proto field number. Such removal is not considered a breaking change. Setting a removed field will effectively result in a no-op.\n', - }, - items: sidebar_api_feature_service_v2, - }, - { - type: "category", - label: "Organization", - link: { - type: "generated-index", - title: "Organization Service API", - slug: "/apis/resources/org_service/v2", - description: - "This API is intended to manage organizations for ZITADEL. \n", - }, - items: sidebar_api_org_service_v2, - }, - { - type: "category", - label: "Identity Provider", - link: { - type: "generated-index", - title: "Identity Provider Service API", - slug: "/apis/resources/idp_service_v2", - description: - "This API is intended to manage identity providers (IdPs) for ZITADEL.\n", - }, - items: sidebar_api_idp_service_v2, - }, - { - type: "category", - label: "Web key (Beta)", - link: { - type: "generated-index", - title: "Web Key Service API (Beta)", - slug: "/apis/resources/webkey_service_v2", - description: - "This API is intended to manage web keys for a ZITADEL instance, used to sign and validate OIDC tokens.\n" + - "\n" + - "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ - "\n"+ - "The public key endpoint (outside of this service) is used to retrieve the public keys of the active and inactive keys.\n"+ - "\n"+ - "Please make sure to enable the `web_key` feature flag on your instance to use this service and that you're running ZITADEL V3.", - }, - items: sidebar_api_webkey_service_v2 - }, - { - type: "category", - label: "Action (Beta)", - link: { - type: "generated-index", - title: "Action Service API (Beta)", - slug: "/apis/resources/action_service_v2", - description: - "This API is intended to manage custom executions and targets (previously known as actions) in a ZITADEL instance.\n" + - "\n" + - "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ - "\n" + - "The version 2 of actions provide much more options to customize ZITADELs behaviour than previous action versions.\n" + - "Also, v2 actions are available instance-wide, whereas previous actions had to be managed for each organization individually\n" + - "ZITADEL doesn't restrict the implementation languages, tooling and runtime for v2 action executions anymore.\n" + - "Instead, it calls external endpoints which are implemented and maintained by action v2 users.\n"+ - "\n" + - "Please make sure to enable the `actions` feature flag on your instance to use this service and that you're running Zitadel V3.", - }, - items: sidebar_api_actions_v2, - }, + "apis/migration_v1_to_v2" ], }, { diff --git a/docs/yarn.lock b/docs/yarn.lock index c933386f97..cb0c5c8381 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -29,126 +29,126 @@ resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.9.tgz#5f38868f7cb1d54b014b17a10fc4f7e79d427fa8" integrity sha512-iDf05JDQ7I0b7JEA/9IektxN/80a2MZ1ToohfmNS3rfeuQnIKI3IJlIafD0xu4StbtQTghx9T3Maa97ytkXenQ== -"@algolia/client-abtesting@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.25.0.tgz#012204f1614e1a71366fb1e117c8f195186ff081" - integrity sha512-1pfQulNUYNf1Tk/svbfjfkLBS36zsuph6m+B6gDkPEivFmso/XnRgwDvjAx80WNtiHnmeNjIXdF7Gos8+OLHqQ== +"@algolia/client-abtesting@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.29.0.tgz#af9928f3b206cc5224e30256ea27d4e4d6023f22" + integrity sha512-AM/6LYMSTnZvAT5IarLEKjYWOdV+Fb+LVs8JRq88jn8HH6bpVUtjWdOZXqX1hJRXuCAY8SdQfb7F8uEiMNXdYQ== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-analytics@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.25.0.tgz#eba015bfafb3dbb82712c9160a00717a5974ff71" - integrity sha512-AFbG6VDJX/o2vDd9hqncj1B6B4Tulk61mY0pzTtzKClyTDlNP0xaUiEKhl6E7KO9I/x0FJF5tDCm0Hn6v5x18A== +"@algolia/client-analytics@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.29.0.tgz#d71b2f6e6c77c390343ee0ab73806378adb295eb" + integrity sha512-La34HJh90l0waw3wl5zETO8TuukeUyjcXhmjYZL3CAPLggmKv74mobiGRIb+mmBENybiFDXf/BeKFLhuDYWMMQ== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-common@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.25.0.tgz#2def8947efe849266057d92f67d1b8d83de0c005" - integrity sha512-il1zS/+Rc6la6RaCdSZ2YbJnkQC6W1wiBO8+SH+DE6CPMWBU6iDVzH0sCKSAtMWl9WBxoN6MhNjGBnCv9Yy2bA== +"@algolia/client-common@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.29.0.tgz#0908e90c5dc881be08eab4e595bf981e23525474" + integrity sha512-T0lzJH/JiCxQYtCcnWy7Jf1w/qjGDXTi2npyF9B9UsTvXB97GRC6icyfXxe21mhYvhQcaB1EQ/J2575FXxi2rA== -"@algolia/client-insights@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.25.0.tgz#b87df8614b96c4cc9c9aa7765cce07fa70864fa8" - integrity sha512-blbjrUH1siZNfyCGeq0iLQu00w3a4fBXm0WRIM0V8alcAPo7rWjLbMJMrfBtzL9X5ic6wgxVpDADXduGtdrnkw== +"@algolia/client-insights@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.29.0.tgz#80ca3c3d16ff2fa78b3a6a091a10ae508977dffa" + integrity sha512-A39F1zmHY9aev0z4Rt3fTLcGN5AG1VsVUkVWy6yQG5BRDScktH+U5m3zXwThwniBTDV1HrPgiGHZeWb67GkR2Q== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-personalization@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.25.0.tgz#74b041f0e7d91e1009c131c8d716c34e4d45c30f" - integrity sha512-aywoEuu1NxChBcHZ1pWaat0Plw7A8jDMwjgRJ00Mcl7wGlwuPt5dJ/LTNcg3McsEUbs2MBNmw0ignXBw9Tbgow== +"@algolia/client-personalization@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.29.0.tgz#1bc8882fe889ad25132794b7beecf1cfc0783acc" + integrity sha512-ibxmh2wKKrzu5du02gp8CLpRMeo+b/75e4ORct98CT7mIxuYFXowULwCd6cMMkz/R0LpKXIbTUl15UL5soaiUQ== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-query-suggestions@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.25.0.tgz#e92d935d9e2994f790d43c64d3518d81070a3888" - integrity sha512-a/W2z6XWKjKjIW1QQQV8PTTj1TXtaKx79uR3NGBdBdGvVdt24KzGAaN7sCr5oP8DW4D3cJt44wp2OY/fZcPAVA== +"@algolia/client-query-suggestions@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.29.0.tgz#784001417cee2ffde376f10074a477eef1eb095d" + integrity sha512-VZq4/AukOoJC2WSwF6J5sBtt+kImOoBwQc1nH3tgI+cxJBg7B77UsNC+jT6eP2dQCwGKBBRTmtPLUTDDnHpMgA== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-search@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.25.0.tgz#dc38ca1015f2f4c9f5053a4517f96fb28a2117f8" - integrity sha512-9rUYcMIBOrCtYiLX49djyzxqdK9Dya/6Z/8sebPn94BekT+KLOpaZCuc6s0Fpfq7nx5J6YY5LIVFQrtioK9u0g== +"@algolia/client-search@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.29.0.tgz#91c9a036b6677d954cd87d9262850f73f145bf81" + integrity sha512-cZ0Iq3OzFUPpgszzDr1G1aJV5UMIZ4VygJ2Az252q4Rdf5cQMhYEIKArWY/oUjMhQmosM8ygOovNq7gvA9CdCg== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" "@algolia/events@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@algolia/events/-/events-4.0.1.tgz#fd39e7477e7bc703d7f893b556f676c032af3950" integrity sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ== -"@algolia/ingestion@1.25.0": - version "1.25.0" - resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.25.0.tgz#4d13c56dda0a05c7bacb0e3ef5866292dfd86ed5" - integrity sha512-jJeH/Hk+k17Vkokf02lkfYE4A+EJX+UgnMhTLR/Mb+d1ya5WhE+po8p5a/Nxb6lo9OLCRl6w3Hmk1TX1e9gVbQ== +"@algolia/ingestion@1.29.0": + version "1.29.0" + resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.29.0.tgz#9d7f30a7161b1cb612309f8240aa471faac8a21f" + integrity sha512-scBXn0wO5tZCxmO6evfa7A3bGryfyOI3aoXqSQBj5SRvNYXaUlFWQ/iKI70gRe/82ICwE0ICXbHT/wIvxOW7vw== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/monitoring@1.25.0": - version "1.25.0" - resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.25.0.tgz#d59360cfe556338519d05a9d8107147e9dbcb020" - integrity sha512-Ls3i1AehJ0C6xaHe7kK9vPmzImOn5zBg7Kzj8tRYIcmCWVyuuFwCIsbuIIz/qzUf1FPSWmw0TZrGeTumk2fqXg== +"@algolia/monitoring@1.29.0": + version "1.29.0" + resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.29.0.tgz#919f86b7c53f1ea7c78f4c0ed9bd7917c1ca3a67" + integrity sha512-FGWWG9jLFhsKB7YiDjM2dwQOYnWu//7Oxrb2vT96N7+s+hg1mdHHfHNRyEudWdxd4jkMhBjeqNA21VbTiOIPVg== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/recommend@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.25.0.tgz#b96f12c85aa74a0326982c7801fcd4a610b420f4" - integrity sha512-79sMdHpiRLXVxSjgw7Pt4R1aNUHxFLHiaTDnN2MQjHwJ1+o3wSseb55T9VXU4kqy3m7TUme3pyRhLk5ip/S4Mw== +"@algolia/recommend@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.29.0.tgz#8f2e5fe2e43e6d1dfa488b4c095404e46d0e1b0c" + integrity sha512-xte5+mpdfEARAu61KXa4ewpjchoZuJlAlvQb8ptK6hgHlBHDnYooy1bmOFpokaAICrq/H9HpoqNUX71n+3249A== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/requester-browser-xhr@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.25.0.tgz#c194fa5f49206b9343e6646c41bfbca2a3f2ac54" - integrity sha512-JLaF23p1SOPBmfEqozUAgKHQrGl3z/Z5RHbggBu6s07QqXXcazEsub5VLonCxGVqTv6a61AAPr8J1G5HgGGjEw== +"@algolia/requester-browser-xhr@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.29.0.tgz#c3cec914716160d3d972ff09b3b35093916cb5bb" + integrity sha512-og+7Em75aPHhahEUScq2HQ3J7ULN63Levtd87BYMpn6Im5d5cNhaC4QAUsXu6LWqxRPgh4G+i+wIb6tVhDhg2A== dependencies: - "@algolia/client-common" "5.25.0" + "@algolia/client-common" "5.29.0" -"@algolia/requester-fetch@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.25.0.tgz#231a2d0da2397d141f80b8f28e2cb6e3d219d38d" - integrity sha512-rtzXwqzFi1edkOF6sXxq+HhmRKDy7tz84u0o5t1fXwz0cwx+cjpmxu/6OQKTdOJFS92JUYHsG51Iunie7xbqfQ== +"@algolia/requester-fetch@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.29.0.tgz#3d885d73ab116c4c1ae88e7e6fb3b022cba45ce8" + integrity sha512-JCxapz7neAy8hT/nQpCvOrI5JO8VyQ1kPvBiaXWNC1prVq0UMYHEL52o1BsPvtXfdQ7BVq19OIq6TjOI06mV/w== dependencies: - "@algolia/client-common" "5.25.0" + "@algolia/client-common" "5.29.0" -"@algolia/requester-node-http@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.25.0.tgz#0ce13c550890de21c558b04381535d2d245a3725" - integrity sha512-ZO0UKvDyEFvyeJQX0gmZDQEvhLZ2X10K+ps6hViMo1HgE2V8em00SwNsQ+7E/52a+YiBkVWX61pJJJE44juDMQ== +"@algolia/requester-node-http@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.29.0.tgz#9e8fb975c392ba1a99b8774856cfc892ed17819e" + integrity sha512-lVBD81RBW5VTdEYgnzCz7Pf9j2H44aymCP+/eHGJu4vhU+1O8aKf3TVBgbQr5UM6xoe8IkR/B112XY6YIG2vtg== dependencies: - "@algolia/client-common" "5.25.0" + "@algolia/client-common" "5.29.0" "@alloc/quick-lru@^5.2.0": version "5.2.0" @@ -217,9 +217,9 @@ integrity sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw== "@babel/compat-data@^7.27.2": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.3.tgz#cc49c2ac222d69b889bf34c795f537c0c6311111" - integrity sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82" + integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg== "@babel/core@^7.21.3": version "7.24.7" @@ -243,19 +243,19 @@ semver "^6.3.1" "@babel/core@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.3.tgz#d7d05502bccede3cab36373ed142e6a1df554c2f" - integrity sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA== + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce" + integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g== dependencies: "@ampproject/remapping" "^2.2.0" "@babel/code-frame" "^7.27.1" "@babel/generator" "^7.27.3" "@babel/helper-compilation-targets" "^7.27.2" "@babel/helper-module-transforms" "^7.27.3" - "@babel/helpers" "^7.27.3" - "@babel/parser" "^7.27.3" + "@babel/helpers" "^7.27.4" + "@babel/parser" "^7.27.4" "@babel/template" "^7.27.2" - "@babel/traverse" "^7.27.3" + "@babel/traverse" "^7.27.4" "@babel/types" "^7.27.3" convert-source-map "^2.0.0" debug "^4.1.0" @@ -274,11 +274,11 @@ jsesc "^2.5.1" "@babel/generator@^7.25.9", "@babel/generator@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.3.tgz#ef1c0f7cfe3b5fc8cbb9f6cc69f93441a68edefc" - integrity sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.5.tgz#3eb01866b345ba261b04911020cbe22dd4be8c8c" + integrity sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw== dependencies: - "@babel/parser" "^7.27.3" + "@babel/parser" "^7.27.5" "@babel/types" "^7.27.3" "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" @@ -628,13 +628,13 @@ "@babel/template" "^7.27.0" "@babel/types" "^7.27.0" -"@babel/helpers@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.3.tgz#387d65d279290e22fe7a47a8ffcd2d0c0184edd0" - integrity sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg== +"@babel/helpers@^7.27.4": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.6.tgz#6456fed15b2cb669d2d1fabe84b66b34991d812c" + integrity sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug== dependencies: "@babel/template" "^7.27.2" - "@babel/types" "^7.27.3" + "@babel/types" "^7.27.6" "@babel/highlight@^7.24.7": version "7.24.7" @@ -658,10 +658,10 @@ dependencies: "@babel/types" "^7.27.0" -"@babel/parser@^7.27.2", "@babel/parser@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.3.tgz#1b7533f0d908ad2ac545c4d05cbe2fb6dc8cfaaf" - integrity sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw== +"@babel/parser@^7.27.2", "@babel/parser@^7.27.4", "@babel/parser@^7.27.5": + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.5.tgz#ed22f871f110aa285a6fd934a0efed621d118826" + integrity sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg== dependencies: "@babel/types" "^7.27.3" @@ -983,9 +983,9 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-transform-block-scoping@^7.27.1": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.3.tgz#a21f37e222dc0a7b91c3784fa3bd4edf8d7a6dc1" - integrity sha512-+F8CnfhuLhwUACIJMLWnjz6zvzYM2r0yeIHKlbgfw7ml8rOMJsXNXV/hyRcb3nb493gRs4WvYpQAndWj/qQmkQ== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.5.tgz#98c37485d815533623d992fd149af3e7b3140157" + integrity sha512-JF6uE2s67f0y2RZcm2kpAUEbD50vH62TyWVebxwHAlbSdM49VqPz8t4a1uIjp4NIOIZ4xzLfjY5emt/RCyC7TQ== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -1595,9 +1595,9 @@ regenerator-transform "^0.15.2" "@babel/plugin-transform-regenerator@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz#0a471df9213416e44cd66bf67176b66f65768401" - integrity sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.5.tgz#0c01f4e0e4cced15f68ee14b9c76dac9813850c7" + integrity sha512-uhB8yHerfe3MWnuLAhEbeQ4afVoqv8BQsPqrTv7e/jZ9y00kJL6l9a/f4OWaKxotmjzewfEyXE1vgDJenkQ2/Q== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -1624,9 +1624,9 @@ "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-runtime@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.3.tgz#ad35f1eff5ba18a5e23f7270e939fb5a59d3ec0b" - integrity sha512-bA9ZL5PW90YwNgGfjg6U+7Qh/k3zCEQJ06BFgAGRp/yMjw9hP9UGbGPtx3KSOkHGljEPCCxaE+PH4fUR2h1sDw== + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.4.tgz#dee5c5db6543313d1ae1b4b1ec122ff1e77352b9" + integrity sha512-D68nR5zxU64EUzV8i7T3R5XP0Xhrou/amNnddsRQssx6GrTLdZl1rLxyjtVZBd+v/NVX4AbTPOB5aU8thAZV1A== dependencies: "@babel/helper-module-imports" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" @@ -2013,13 +2013,13 @@ integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== "@babel/runtime-corejs3@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.27.3.tgz#b971a4a0a376171e266629152e74ef50e9931f79" - integrity sha512-ZYcgrwb+dkWNcDlsTe4fH1CMdqMDSJ5lWFd1by8Si2pI54XcQjte/+ViIPqAk7EAWisaUxvQ89grv+bNX2x8zg== + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.27.6.tgz#97644153808a62898e7c05f3361501417db3c48b" + integrity sha512-vDVrlmRAY8z9Ul/HxT+8ceAru95LQgkSKiXkSYZvqtbkPSfhZJgpRp45Cldbh1GJ1kxzQkI70AqyrTI58KpaWQ== dependencies: core-js-pure "^3.30.2" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.20.13", "@babel/runtime@^7.23.2", "@babel/runtime@^7.26.0", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.27.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762" integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw== @@ -2027,9 +2027,9 @@ regenerator-runtime "^0.14.0" "@babel/runtime@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.3.tgz#10491113799fb8d77e1d9273384d5d68deeea8f6" - integrity sha512-7EYtGezsdiDMyY80+65EzwiGmcJqpmcZCojSXaRgdrBaGtWTgDZKq69cPIVped6MkIM78cTQ2GOiEYjwOlG4xw== + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" + integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== "@babel/template@^7.24.7": version "7.24.7" @@ -2074,14 +2074,14 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.3.tgz#8b62a6c2d10f9d921ba7339c90074708509cffae" - integrity sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ== +"@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.27.4": + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.4.tgz#b0045ac7023c8472c3d35effd7cc9ebd638da6ea" + integrity sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA== dependencies: "@babel/code-frame" "^7.27.1" "@babel/generator" "^7.27.3" - "@babel/parser" "^7.27.3" + "@babel/parser" "^7.27.4" "@babel/template" "^7.27.2" "@babel/types" "^7.27.3" debug "^4.3.1" @@ -2104,10 +2104,10 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" -"@babel/types@^7.27.1", "@babel/types@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.3.tgz#c0257bedf33aad6aad1f406d35c44758321eb3ec" - integrity sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw== +"@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.6": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.6.tgz#a434ca7add514d4e646c80f7375c0aa2befc5535" + integrity sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q== dependencies: "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" @@ -3072,6 +3072,33 @@ resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-5.5.3.tgz#18e3af6b8eae7984072bbeb0c0858474d7c4cefe" integrity sha512-R11tGE6yIFwqpaIqcfkcg7AICXzFg14+5h5v0TfF/9+RMDL6jhzCy/pxHVOfbALGdtVYdt6JdR21tuxEgl34dw== +"@floating-ui/core@^1.6.0": + version "1.6.9" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.9.tgz#64d1da251433019dafa091de9b2886ff35ec14e6" + integrity sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw== + dependencies: + "@floating-ui/utils" "^0.2.9" + +"@floating-ui/dom@^1.0.0": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.13.tgz#a8a938532aea27a95121ec16e667a7cbe8c59e34" + integrity sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w== + dependencies: + "@floating-ui/core" "^1.6.0" + "@floating-ui/utils" "^0.2.9" + +"@floating-ui/react-dom@^2.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31" + integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A== + dependencies: + "@floating-ui/dom" "^1.0.0" + +"@floating-ui/utils@^0.2.9": + version "0.2.9" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429" + integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg== + "@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -3121,6 +3148,104 @@ local-pkg "^1.0.0" mlly "^1.7.4" +"@inkeep/cxkit-color-mode@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-color-mode/-/cxkit-color-mode-0.5.89.tgz#4a5471b3dc453262ef0277908c30e108b1095331" + integrity sha512-h89/i67uEiJh0Bqf/dt9nJWv3IjCnmu96nkomocZ3evPKrLeBq13IygFIxtvmvXfx1QBBdmAD+x933rc5RcgFA== + +"@inkeep/cxkit-docusaurus@^0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-docusaurus/-/cxkit-docusaurus-0.5.89.tgz#aff4035fe2bb69401d1677393dcd2943bf3e0d1f" + integrity sha512-z4OxGLoPVbk6ZcKW5i/MrGJUx6Wyc5zh2mxOt2IHTQgFL3XiArCKds0R8jSxSi8SB/a9j5Wm/X0FinT+or9NKA== + dependencies: + "@inkeep/cxkit-react" "0.5.89" + merge-anything "5.1.7" + path "^0.12.7" + +"@inkeep/cxkit-primitives@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-primitives/-/cxkit-primitives-0.5.89.tgz#1f8252f18754aab2c28dd0e1436381b4323d2c0b" + integrity sha512-ugC80ivuimKmzcm8RktAL9C7YHss7CneerbHnNy+uipCx9ZcIY5dRfgMqIdoLG6fL7fhSm6E0QElGL8YjjfN6g== + dependencies: + "@inkeep/cxkit-color-mode" "0.5.89" + "@inkeep/cxkit-theme" "0.5.89" + "@inkeep/cxkit-types" "0.5.89" + "@radix-ui/primitive" "^1.1.1" + "@radix-ui/react-avatar" "1.1.2" + "@radix-ui/react-checkbox" "1.1.3" + "@radix-ui/react-compose-refs" "^1.1.1" + "@radix-ui/react-context" "^1.1.1" + "@radix-ui/react-dismissable-layer" "^1.1.5" + "@radix-ui/react-focus-guards" "^1.1.1" + "@radix-ui/react-focus-scope" "^1.1.2" + "@radix-ui/react-hover-card" "^1.1.6" + "@radix-ui/react-id" "^1.1.0" + "@radix-ui/react-popover" "1.1.6" + "@radix-ui/react-portal" "^1.1.4" + "@radix-ui/react-presence" "^1.1.2" + "@radix-ui/react-primitive" "^2.0.2" + "@radix-ui/react-scroll-area" "1.2.2" + "@radix-ui/react-select" "^2.1.7" + "@radix-ui/react-slot" "^1.2.0" + "@radix-ui/react-tabs" "^1.1.4" + "@radix-ui/react-tooltip" "1.1.6" + "@radix-ui/react-use-callback-ref" "^1.1.0" + "@radix-ui/react-use-controllable-state" "^1.1.0" + "@zag-js/focus-trap" "^1.7.0" + "@zag-js/presence" "^1.13.1" + "@zag-js/react" "^1.13.1" + altcha-lib "^1.2.0" + aria-hidden "^1.2.4" + dequal "^2.0.3" + humps "2.0.1" + lucide-react "^0.503.0" + marked "^15.0.9" + merge-anything "5.1.7" + openai "4.78.1" + prism-react-renderer "2.4.1" + react-error-boundary "^6.0.0" + react-hook-form "7.54.2" + react-markdown "9.0.3" + react-remove-scroll "^2.7.1" + react-svg "16.3.0" + react-textarea-autosize "8.5.7" + rehype-raw "7.0.0" + remark-gfm "^4.0.1" + unist-util-visit "^5.0.0" + use-sync-external-store "^1.4.0" + +"@inkeep/cxkit-react@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-react/-/cxkit-react-0.5.89.tgz#4bc37852bc6161ed4dc5b44b3ceb8beddf49f6f8" + integrity sha512-v86J6xe86kgKfDzlNGZSGHQ3PB8KJ46ra8xnVV4RrRjp7kPGiR7MvGfalFcSiaSEQzWiAcKAl4gya/AF/J/OZw== + dependencies: + "@inkeep/cxkit-styled" "0.5.89" + "@radix-ui/react-use-controllable-state" "^1.1.0" + lucide-react "^0.503.0" + +"@inkeep/cxkit-styled@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-styled/-/cxkit-styled-0.5.89.tgz#e113ee393f5055457281a52cfb8221dd14f70661" + integrity sha512-w9V3vYuq4ytluow16RO/0/V1s9PSBqOjZdvATdn+jy06gUn5ClNAiJ79m34fB3Ep0Y6o2m+obujX4njw4A+LPw== + dependencies: + "@inkeep/cxkit-primitives" "0.5.89" + class-variance-authority "0.7.1" + clsx "2.1.1" + merge-anything "5.1.7" + tailwind-merge "2.6.0" + +"@inkeep/cxkit-theme@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-theme/-/cxkit-theme-0.5.89.tgz#b1f9f7be2a87f25b8c6b2c4eb654b998b6bd7cc8" + integrity sha512-Yji2OCDi2buYZQXY4tw93U6W3ZFDaNw7wgGLP+vTyRZaFBMGwW52zNOjewz2UL1jNGl6ublHXmWM+kjIC4b5SQ== + dependencies: + colorjs.io "0.5.2" + +"@inkeep/cxkit-types@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-types/-/cxkit-types-0.5.89.tgz#f8db85cca7c8dbb72c6a035882435b5e0e86ca76" + integrity sha512-zz6945Ex9kSpIUeZaVAX4h6HeCaOt28BzZyuprQWXIpzvAlFKuKDNV1Zm5umEglaiGSx+T9J6WViy3PoRXwTtA== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -3238,10 +3363,10 @@ dependencies: "@types/mdx" "^2.0.0" -"@mermaid-js/parser@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.4.0.tgz#c1de1f5669f8fcbd0d0c9d124927d36ddc00d8a6" - integrity sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA== +"@mermaid-js/parser@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.5.0.tgz#63d676e930b0cfd6abfeadee46fb228761438ce6" + integrity sha512-AiaN7+VjXC+3BYE+GwNezkpjIcCI2qIMB/K4S2/vMWe0q/XJCBbx5+K7iteuz7VyltX9iAK4FmVTvGc9kjOV4w== dependencies: langium "3.3.1" @@ -3429,6 +3554,551 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.25.tgz#f077fdc0b5d0078d30893396ff4827a13f99e817" integrity sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ== +"@radix-ui/number@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.0.tgz#1e95610461a09cdf8bb05c152e76ca1278d5da46" + integrity sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ== + +"@radix-ui/number@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" + integrity sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g== + +"@radix-ui/primitive@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.1.tgz#fc169732d755c7fbad33ba8d0cd7fd10c90dc8e3" + integrity sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA== + +"@radix-ui/primitive@1.1.2", "@radix-ui/primitive@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.2.tgz#83f415c4425f21e3d27914c12b3272a32e3dae65" + integrity sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA== + +"@radix-ui/react-arrow@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz#2103721933a8bfc6e53bbfbdc1aaad5fc8ba0dd7" + integrity sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + +"@radix-ui/react-arrow@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz#30c0d574d7bb10eed55cd7007b92d38b03c6b2ab" + integrity sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg== + dependencies: + "@radix-ui/react-primitive" "2.0.2" + +"@radix-ui/react-arrow@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.3.tgz#8926eb1d87f73c2e047eac96703949f168c85861" + integrity sha512-2dvVU4jva0qkNZH6HHWuSz5FN5GeU5tymvCgutF8WaXz9WnD1NgUhy73cqzkjkN4Zkn8lfTPv5JIfrC221W+Nw== + dependencies: + "@radix-ui/react-primitive" "2.0.3" + +"@radix-ui/react-avatar@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz#24af4c66bb5271460a4a6b74c4f4f9d4789d3d90" + integrity sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig== + dependencies: + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-checkbox@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.1.3.tgz#0e2ab913fddf3c88603625f7a9457d73882c8a32" + integrity sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-use-previous" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + +"@radix-ui/react-collection@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.3.tgz#cfd46dcea5a8ab064d91798feeb46faba4032930" + integrity sha512-mM2pxoQw5HJ49rkzwOs7Y6J4oYH22wS8BfK2/bBxROlI4xuR0c4jEenQP63LlTlDkO6Buj2Vt+QYAYcOgqtrXA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-slot" "1.2.0" + +"@radix-ui/react-compose-refs@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz#6f766faa975f8738269ebb8a23bad4f5a8d2faec" + integrity sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw== + +"@radix-ui/react-compose-refs@1.1.2", "@radix-ui/react-compose-refs@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30" + integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg== + +"@radix-ui/react-context@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.1.tgz#82074aa83a472353bb22e86f11bcbd1c61c4c71a" + integrity sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q== + +"@radix-ui/react-context@1.1.2", "@radix-ui/react-context@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36" + integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA== + +"@radix-ui/react-direction@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz#a7d39855f4d077adc2a1922f9c353c5977a09cdc" + integrity sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg== + +"@radix-ui/react-direction@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz#39e5a5769e676c753204b792fbe6cf508e550a14" + integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw== + +"@radix-ui/react-dismissable-layer@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz#4ee0f0f82d53bf5bd9db21665799bb0d1bad5ed8" + integrity sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + +"@radix-ui/react-dismissable-layer@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz#96dde2be078c694a621e55e047406c58cd5fe774" + integrity sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + +"@radix-ui/react-dismissable-layer@1.1.6", "@radix-ui/react-dismissable-layer@^1.1.5": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.6.tgz#e72c156cac7b07614fe8e3a039ab7081ce413686" + integrity sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-escape-keydown" "1.1.1" + +"@radix-ui/react-focus-guards@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe" + integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg== + +"@radix-ui/react-focus-guards@1.1.2", "@radix-ui/react-focus-guards@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz#4ec9a7e50925f7fb661394460045b46212a33bed" + integrity sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA== + +"@radix-ui/react-focus-scope@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz#c0a4519cd95c772606a82fc5b96226cd7fdd2602" + integrity sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + +"@radix-ui/react-focus-scope@1.1.3", "@radix-ui/react-focus-scope@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.3.tgz#eac83a3aac700db17650b41b30724deffac5b28a" + integrity sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-hover-card@^1.1.6": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-hover-card/-/react-hover-card-1.1.7.tgz#01b2f956daeb8a1193ccdb36c9c00943120bf2d4" + integrity sha512-HwM03kP8psrv21J1+9T/hhxi0f5rARVbqIZl9+IAq13l4j4fX+oGIuxisukZZmebO7J35w9gpoILvtG8bbph0w== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-dismissable-layer" "1.1.6" + "@radix-ui/react-popper" "1.2.3" + "@radix-ui/react-portal" "1.1.5" + "@radix-ui/react-presence" "1.1.3" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-controllable-state" "1.1.1" + +"@radix-ui/react-id@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.0.tgz#de47339656594ad722eb87f94a6b25f9cffae0ed" + integrity sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-id@1.1.1", "@radix-ui/react-id@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7" + integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-popover@1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.6.tgz#699634dbc7899429f657bb590d71fb3ca0904087" + integrity sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.5" + "@radix-ui/react-focus-guards" "1.1.1" + "@radix-ui/react-focus-scope" "1.1.2" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.2" + "@radix-ui/react-portal" "1.1.4" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-slot" "1.1.2" + "@radix-ui/react-use-controllable-state" "1.1.0" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + +"@radix-ui/react-popper@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.1.tgz#2fc66cfc34f95f00d858924e3bee54beae2dff0a" + integrity sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-rect" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + "@radix-ui/rect" "1.1.0" + +"@radix-ui/react-popper@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.2.tgz#d2e1ee5a9b24419c5936a1b7f6f472b7b412b029" + integrity sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-rect" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + "@radix-ui/rect" "1.1.0" + +"@radix-ui/react-popper@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.3.tgz#3b6ef3388fd209bb46341e1e40125b75f07f1304" + integrity sha512-iNb9LYUMkne9zIahukgQmHlSBp9XWGeQQ7FvUGNk45ywzOb6kQa+Ca38OphXlWDiKvyneo9S+KSJsLfLt8812A== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-use-rect" "1.1.1" + "@radix-ui/react-use-size" "1.1.1" + "@radix-ui/rect" "1.1.1" + +"@radix-ui/react-portal@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.3.tgz#b0ea5141103a1671b715481b13440763d2ac4440" + integrity sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-portal@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.4.tgz#ff5401ff63c8a825c46eea96d3aef66074b8c0c8" + integrity sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA== + dependencies: + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-portal@1.1.5", "@radix-ui/react-portal@^1.1.4": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.5.tgz#50ed6bee2d895c9a9dfc28625f24b8483b74d431" + integrity sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA== + dependencies: + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-presence@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.2.tgz#bb764ed8a9118b7ec4512da5ece306ded8703cdc" + integrity sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-presence@1.1.3", "@radix-ui/react-presence@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.3.tgz#ce3400caec9892ceb862f96ddaa2add080c09b90" + integrity sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-primitive@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz#6d9efc550f7520135366f333d1e820cf225fad9e" + integrity sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg== + dependencies: + "@radix-ui/react-slot" "1.1.1" + +"@radix-ui/react-primitive@2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz#ac8b7854d87b0d7af388d058268d9a7eb64ca8ef" + integrity sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w== + dependencies: + "@radix-ui/react-slot" "1.1.2" + +"@radix-ui/react-primitive@2.0.3", "@radix-ui/react-primitive@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz#13c654dc4754558870a9c769f6febe5980a1bad8" + integrity sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g== + dependencies: + "@radix-ui/react-slot" "1.2.0" + +"@radix-ui/react-roving-focus@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.3.tgz#c992b9d30c795f5f5a668853db8f4a6e07b7284d" + integrity sha512-ufbpLUjZiOg4iYgb2hQrWXEPYX6jOLBbR27bDyAff5GYMRrCzcze8lukjuXVUQvJ6HZe8+oL+hhswDcjmcgVyg== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-collection" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.1" + +"@radix-ui/react-scroll-area@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz#28e34fd4d83e9de5d987c5e8914a7bd8be9546a5" + integrity sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g== + dependencies: + "@radix-ui/number" "1.1.0" + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-direction" "1.1.0" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-select@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.1.7.tgz#68561488ca54cad07352b3f2c2d29e0da28bbaa0" + integrity sha512-exzGIRtc7S8EIM2KjFg+7lJZsH7O7tpaBaJbBNVDnOZNhtoQ2iV+iSNfi2Wth0m6h3trJkMVvzAehB3c6xj/3Q== + dependencies: + "@radix-ui/number" "1.1.1" + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-collection" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.6" + "@radix-ui/react-focus-guards" "1.1.2" + "@radix-ui/react-focus-scope" "1.1.3" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-popper" "1.2.3" + "@radix-ui/react-portal" "1.1.5" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-slot" "1.2.0" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-use-previous" "1.1.1" + "@radix-ui/react-visually-hidden" "1.1.3" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + +"@radix-ui/react-slot@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.1.tgz#ab9a0ffae4027db7dc2af503c223c978706affc3" + integrity sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + +"@radix-ui/react-slot@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.2.tgz#daffff7b2bfe99ade63b5168407680b93c00e1c6" + integrity sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + +"@radix-ui/react-slot@1.2.0", "@radix-ui/react-slot@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.0.tgz#57727fc186ddb40724ccfbe294e1a351d92462ba" + integrity sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + +"@radix-ui/react-tabs@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.4.tgz#2e43f3ef3450143281e7c1491da1e5d7941b9826" + integrity sha512-fuHMHWSf5SRhXke+DbHXj2wVMo+ghVH30vhX3XVacdXqDl+J4XWafMIGOOER861QpBx1jxgwKXL2dQnfrsd8MQ== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-presence" "1.1.3" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-roving-focus" "1.1.3" + "@radix-ui/react-use-controllable-state" "1.1.1" + +"@radix-ui/react-tooltip@1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz#eab98e9a5c876ef0abfae3cfeee229870528ed06" + integrity sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.3" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.1" + "@radix-ui/react-portal" "1.1.3" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-visually-hidden" "1.1.1" + +"@radix-ui/react-use-callback-ref@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1" + integrity sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw== + +"@radix-ui/react-use-callback-ref@1.1.1", "@radix-ui/react-use-callback-ref@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40" + integrity sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg== + +"@radix-ui/react-use-controllable-state@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz#1321446857bb786917df54c0d4d084877aab04b0" + integrity sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.0" + +"@radix-ui/react-use-controllable-state@1.1.1", "@radix-ui/react-use-controllable-state@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.1.tgz#ec9c572072a6f269df7435c1652fbeebabe0f0c1" + integrity sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-use-escape-keydown@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz#31a5b87c3b726504b74e05dac1edce7437b98754" + integrity sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.0" + +"@radix-ui/react-use-escape-keydown@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz#b3fed9bbea366a118f40427ac40500aa1423cc29" + integrity sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-use-layout-effect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27" + integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w== + +"@radix-ui/react-use-layout-effect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e" + integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ== + +"@radix-ui/react-use-previous@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz#d4dd37b05520f1d996a384eb469320c2ada8377c" + integrity sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og== + +"@radix-ui/react-use-previous@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz#1a1ad5568973d24051ed0af687766f6c7cb9b5b5" + integrity sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ== + +"@radix-ui/react-use-rect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88" + integrity sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ== + dependencies: + "@radix-ui/rect" "1.1.0" + +"@radix-ui/react-use-rect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz#01443ca8ed071d33023c1113e5173b5ed8769152" + integrity sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w== + dependencies: + "@radix-ui/rect" "1.1.1" + +"@radix-ui/react-use-size@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz#b4dba7fbd3882ee09e8d2a44a3eed3a7e555246b" + integrity sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-use-size@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz#6de276ffbc389a537ffe4316f5b0f24129405b37" + integrity sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-visually-hidden@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz#f7b48c1af50dfdc366e92726aee6d591996c5752" + integrity sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + +"@radix-ui/react-visually-hidden@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.3.tgz#f704c49121859941a8bb50ff1e4f156058cacd0b" + integrity sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ== + dependencies: + "@radix-ui/react-primitive" "2.0.3" + +"@radix-ui/rect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438" + integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg== + +"@radix-ui/rect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" + integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== + "@redocly/ajv@^8.11.0": version "8.11.0" resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.11.0.tgz#2fad322888dc0113af026e08fceb3e71aae495ae" @@ -3561,6 +4231,23 @@ resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== +"@signalwire/docusaurus-plugin-llms-txt@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@signalwire/docusaurus-plugin-llms-txt/-/docusaurus-plugin-llms-txt-1.2.0.tgz#895174eb6a786099dd9517052176a644f99a05e9" + integrity sha512-KkuerZy5VtzeordKL+osxHHAJPs7GcjKEyiC8Up4i2lODE38cVhHtn3F+chxOLPRWXpWUCskFhEJI5r+aZ1+7A== + dependencies: + fs-extra "^11.0.0" + hast-util-select "^6.0.4" + hast-util-to-html "^9.0.5" + hast-util-to-string "^3.0.1" + rehype-parse "^9" + rehype-remark "^10" + remark-gfm "^4" + remark-stringify "^11" + string-width "^5.0.0" + unified "^11" + unist-util-visit "^5" + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -3691,152 +4378,152 @@ "@svgr/plugin-jsx" "8.1.0" "@svgr/plugin-svgo" "8.1.0" -"@swc/core-darwin-arm64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.29.tgz#bf66e3f4f00e6fe9d95e8a33f780e6c40fca946d" - integrity sha512-whsCX7URzbuS5aET58c75Dloby3Gtj/ITk2vc4WW6pSDQKSPDuONsIcZ7B2ng8oz0K6ttbi4p3H/PNPQLJ4maQ== +"@swc/core-darwin-arm64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.12.6.tgz#3d9166720df2dc00fa3b6cf90fce3e77a442a43e" + integrity sha512-yLiw+XzG+MilfFh0ON7qt67bfIr7UxB9JprhYReVOmLTBDmDVQSC3T4/vIuc+GwlX08ydnHy0ud4lIjTNW4uWg== -"@swc/core-darwin-x64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.11.29.tgz#0a77d2d79ef2c789f9d40a86784bbf52c5f9877f" - integrity sha512-S3eTo/KYFk+76cWJRgX30hylN5XkSmjYtCBnM4jPLYn7L6zWYEPajsFLmruQEiTEDUg0gBEWLMNyUeghtswouw== +"@swc/core-darwin-x64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.12.6.tgz#2bbc32c56f8bccf6958b73d46bdd5670aa31f4d9" + integrity sha512-qwg8ux5x5Gd1LmSUtL4s9mbyfzAjr5M6OtjO281dKHwc/GYiSc4j1urb2jNSo9FcMkfT78oAOW2L6HQiWv+j1A== -"@swc/core-linux-arm-gnueabihf@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.29.tgz#80fa3a6a36034ffdbbba73e26c8f27cb13111a33" - integrity sha512-o9gdshbzkUMG6azldHdmKklcfrcMx+a23d/2qHQHPDLUPAN+Trd+sDQUYArK5Fcm7TlpG4sczz95ghN0DMkM7g== +"@swc/core-linux-arm-gnueabihf@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.12.6.tgz#45d8bdef987c4f7fbc5b14640374f0e77904f304" + integrity sha512-pnkqH59JXBZu+MedaykMAC2or7tlUKeya7GKjzub+hkwxBP0ywWoFd+QYEdzp7QSziOt1VIHc4Wb9iZ2EfnzmA== -"@swc/core-linux-arm64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.29.tgz#42da87f445bc3e26da01d494246884006d9b9a1a" - integrity sha512-sLoaciOgUKQF1KX9T6hPGzvhOQaJn+3DHy4LOHeXhQqvBgr+7QcZ+hl4uixPKTzxk6hy6Hb0QOvQEdBAAR1gXw== +"@swc/core-linux-arm64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.12.6.tgz#f69616d4269e11cb93348437687e08f49f58cc36" + integrity sha512-h8+Ltx0NSEzIFHetkOYoQ+UQ59unYLuJ4wF6kCpxzS4HskRLjcngr1HgN0F/PRpptnrmJUPVQmfms/vjN8ndAQ== -"@swc/core-linux-arm64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.29.tgz#c9cec610525dc9e9b11ef26319db3780812dfa54" - integrity sha512-PwjB10BC0N+Ce7RU/L23eYch6lXFHz7r3NFavIcwDNa/AAqywfxyxh13OeRy+P0cg7NDpWEETWspXeI4Ek8otw== +"@swc/core-linux-arm64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.12.6.tgz#3277154e7b60213c0fa11e415c8a90b41563d76e" + integrity sha512-GZu3MnB/5qtBxKEH46hgVDaplEe4mp3ZmQ1O2UpFCv/u/Ji3Gar5w5g2wHCZoT5AOouAhP1bh7IAEqjG/fbVfg== -"@swc/core-linux-x64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.29.tgz#1cda2df38a4ab8905ba6ac3aa16e4ad710b6f2de" - integrity sha512-i62vBVoPaVe9A3mc6gJG07n0/e7FVeAvdD9uzZTtGLiuIfVfIBta8EMquzvf+POLycSk79Z6lRhGPZPJPYiQaA== +"@swc/core-linux-x64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.12.6.tgz#7d04d9437011396bf54c4ab0686c4db65a1283f8" + integrity sha512-WwJLQFzMW9ufVjM6k3le4HUgBFNunyt2oghjcgn2YjnKj0Ka2LrrBHCxfS7lgFSCQh/shib2wIlKXUnlTEWQJw== -"@swc/core-linux-x64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.29.tgz#5d634efff33f47c8d6addd84291ab606903d1cfd" - integrity sha512-YER0XU1xqFdK0hKkfSVX1YIyCvMDI7K07GIpefPvcfyNGs38AXKhb2byySDjbVxkdl4dycaxxhRyhQ2gKSlsFQ== +"@swc/core-linux-x64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.12.6.tgz#2f3e586eda3ee5d08b6b8de7714623d5a44ceb3c" + integrity sha512-rVGPNpI/sm8VVAhnB09Z/23OJP3ymouv6F4z4aYDbq/2JIwxqgpnl8gtMYP+Jw3XqabaFNjQmPiL15TvKCQaxQ== -"@swc/core-win32-arm64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.29.tgz#bc54f2e3f8f180113b7a092b1ee1eaaab24df62b" - integrity sha512-po+WHw+k9g6FAg5IJ+sMwtA/fIUL3zPQ4m/uJgONBATCVnDDkyW6dBA49uHNVtSEvjvhuD8DVWdFP847YTcITw== +"@swc/core-win32-arm64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.12.6.tgz#70211534238ed941efaf0a1b34310e937bd4afa7" + integrity sha512-EKDJ1+8vaIlJGMl2yvd2HklV4GNbpKKwNQcUQid6j91tFYz4/aByw+9vh/sDVG7ZNqdmdywSnLRo317UTt0zFg== -"@swc/core-win32-ia32-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.29.tgz#f1df344c06283643d1fe66c6931b350347b73722" - integrity sha512-h+NjOrbqdRBYr5ItmStmQt6x3tnhqgwbj9YxdGPepbTDamFv7vFnhZR0YfB3jz3UKJ8H3uGJ65Zw1VsC+xpFkg== +"@swc/core-win32-ia32-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.12.6.tgz#d3584da07b47904b547baced8b00c9e4d32110e7" + integrity sha512-jnULikZkR2fpZgFUQs7NsNIztavM1JdX+8Y+8FsfChBvMvziKxXtvUPGjeVJ8nzU1wgMnaeilJX9vrwuDGkA0Q== -"@swc/core-win32-x64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.29.tgz#a6f9dc1df66c8db96d70091abedd78cc52544724" - integrity sha512-Q8cs2BDV9wqDvqobkXOYdC+pLUSEpX/KvI0Dgfun1F+LzuLotRFuDhrvkU9ETJA6OnD2+Fn/ieHgloiKA/Mn/g== +"@swc/core-win32-x64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.12.6.tgz#8eff8c0d9e7c3b91bd9427e67483be90070b0f7d" + integrity sha512-jL2Dcdcc/QZiQnwByP1uIE4k/mTlapzUng7owtLD2tSBBi1d+jPIdXIefdv+nccYJKRA+lKG3rRB6Tk9GrC7Kg== "@swc/core@^1.7.39": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.11.29.tgz#bce20113c47fcd6251d06262b8b8c063f8e86a20" - integrity sha512-g4mThMIpWbNhV8G2rWp5a5/Igv8/2UFRJx2yImrLGMgrDDYZIopqZ/z0jZxDgqNA1QDx93rpwNF7jGsxVWcMlA== + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.12.6.tgz#1bf204f7afc59fde6cb2cef067de23af232d6ff6" + integrity sha512-TEpta6Gi02X1b2yDIzBOIr7dFprvq9jD8RbEVI2OcMrwklbCUx0Dz9TrAnklSOwRvYvH5JjCx8ht9E94oWiG7A== dependencies: "@swc/counter" "^0.1.3" - "@swc/types" "^0.1.21" + "@swc/types" "^0.1.23" optionalDependencies: - "@swc/core-darwin-arm64" "1.11.29" - "@swc/core-darwin-x64" "1.11.29" - "@swc/core-linux-arm-gnueabihf" "1.11.29" - "@swc/core-linux-arm64-gnu" "1.11.29" - "@swc/core-linux-arm64-musl" "1.11.29" - "@swc/core-linux-x64-gnu" "1.11.29" - "@swc/core-linux-x64-musl" "1.11.29" - "@swc/core-win32-arm64-msvc" "1.11.29" - "@swc/core-win32-ia32-msvc" "1.11.29" - "@swc/core-win32-x64-msvc" "1.11.29" + "@swc/core-darwin-arm64" "1.12.6" + "@swc/core-darwin-x64" "1.12.6" + "@swc/core-linux-arm-gnueabihf" "1.12.6" + "@swc/core-linux-arm64-gnu" "1.12.6" + "@swc/core-linux-arm64-musl" "1.12.6" + "@swc/core-linux-x64-gnu" "1.12.6" + "@swc/core-linux-x64-musl" "1.12.6" + "@swc/core-win32-arm64-msvc" "1.12.6" + "@swc/core-win32-ia32-msvc" "1.12.6" + "@swc/core-win32-x64-msvc" "1.12.6" "@swc/counter@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== -"@swc/html-darwin-arm64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.11.29.tgz#7bd6d10115ffe155ecd757387b5aff318b02b5a0" - integrity sha512-q53kn/HI0n/+pecsOB2gxqITbRAhtBG7VI520SIWuCGXHPsTQ/1VOrhLMNvyfw1xVhRyFal7BpAvfGUORCl0sw== +"@swc/html-darwin-arm64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.12.6.tgz#dff9ee656cd1a4ac0a4a2637c4e9a058ce64b42a" + integrity sha512-McW4JsF5wFB5KmHyAaty94kw2hHLbYtrIQvVlshbXM3lpY+rDO0KnS74CcIiAD46p7knV0Y6Xuhint8K3rYfkg== -"@swc/html-darwin-x64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.11.29.tgz#ccafed56081932ffaa51be1788cef88eb9a144d1" - integrity sha512-YfQPjh5WoDqOxsA7vDOOSnxEPc1Ki4SuZ0ufR4t8jYdMOFsU3AhZQ/sgBZLpTzegBTutUn7/7yy8VSoFngeR7Q== +"@swc/html-darwin-x64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.12.6.tgz#194456c256cb5f949af24cfaf1740fb20098285a" + integrity sha512-Fh/bPNdnSNeJ7GrRAe/BqERWV9hbIyZktoMlvkMipz2NPTdadIwXjd8fscVDc6S5j1DigiSp2Mnf0rZgH6Xnhw== -"@swc/html-linux-arm-gnueabihf@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.11.29.tgz#e784a1a0f69034e9dd52a9019ff80f0d5eb91433" - integrity sha512-dC3aEv1mqAUkY9TiZWOE2IcYpvxJzw0LdvkDzGW5072JSlZZYQMqq2Llwg63LIp6qBlj1JLHMLnBqk7Ubatmjw== +"@swc/html-linux-arm-gnueabihf@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.12.6.tgz#aeeeb4b27c06d0f813bbebd6ed48e471fb5873b8" + integrity sha512-F0Z2Fmvdw4vTmmJyFZaGMklrZkrtT9A5d8K1Ez2f7SZwhU09e2cgi49PCHL7wBfc5MBItnugdVJKYi/V6O/Jsg== -"@swc/html-linux-arm64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.11.29.tgz#ef015d81d3a011d6c273a428eee130b3aac790b7" - integrity sha512-seo+lCiBUggTR9NsHE4qVC+7+XIfLHK7yxWiIsXb8nNAXDcqVZ0Rxv8O1Y1GTeJfUlcCt1koahCG2AeyWpYFBg== +"@swc/html-linux-arm64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.12.6.tgz#5aaf98ff9bced40c70a8662dde73db3a1bf21991" + integrity sha512-2S9hXG5EvDMHdjeiVANft+mZ+dRUrqUqKEAM0GehxsnG/ITT4uTolI3u/upMo7t1leOMWcz85hJZqDbVtfyP5Q== -"@swc/html-linux-arm64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.11.29.tgz#b20e4b442287367c4c1d62db2b8065106542f432" - integrity sha512-bK8K6t3hHgaZZ1vMNaZ+8x42EWJPEX1Dx4zi6ulMhKa1uan+DjW5SiMlUg0an16fFSYfE+r9oFC4cFEbGP1o4Q== +"@swc/html-linux-arm64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.12.6.tgz#7f658b07b41ca7910d1af980988549d638666ebc" + integrity sha512-RqKvGk4V2HpEObFva1AbhhEpvH8VrRI1sRjHZW7I0kTWZZkg13tJXSmGhIAfUgJFGWvvVSoZ/8TSyRq8Ju6Pvw== -"@swc/html-linux-x64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.11.29.tgz#c45505b3e22c02dc8bdef8b3da48ba855527a62c" - integrity sha512-34tSms5TkRUCr+J6uuSE/11ECcfIpp5R1ODuIgxZRUd/u88pQGKzLVNLWGPLw4b3cZSjnAn+PFJl7BtaYl0UyQ== +"@swc/html-linux-x64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.12.6.tgz#b32b9f6fa65ef322d499e35ac0d1599ec425ad93" + integrity sha512-nZzjhrya4VFfT2jX2EYe+FF1EzeghHAB5wyOASFN35CxOpJMhr/04COu5uRggZGYD+19s1LrLelKhSOBAPDrOw== -"@swc/html-linux-x64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.11.29.tgz#86116e7db3cf02b1a627bc94c374d26ce6f9d68a" - integrity sha512-oJLLrX94ccaniWdQt8PH6K2u8aN/ehBo/YPg84LycFtaud/k73Fa1kh6Neq8vbWI4CugIWTl4LXWoHm+l+QYeA== +"@swc/html-linux-x64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.12.6.tgz#ac03730723528385f71b2a30b635002eea4f23e1" + integrity sha512-hJdSZw5lo+Ws355gs6M2cV4QTbRmc6Ide7kUYMoSQQFyZVc05am2sulPLfOTHmzV8BW3QZ3apO7wcKTzJ/yBbQ== -"@swc/html-win32-arm64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.11.29.tgz#cf7e57b8c0b52f7f93abc307b0cb78d8213b3c13" - integrity sha512-nw4TCFfA4YV6jicRdicJZPKW+ihOZPMKEG/4bj1/6HqXw1T2pXI070ASOLE0KOHYuoyV/jConEHfIjlU0olneA== +"@swc/html-win32-arm64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.12.6.tgz#10fd59442feda8e3d0ce6dfccd3bef6fc1e01440" + integrity sha512-l7kFWXr4/A5joeJBSft8oGMVxXOORu6oKMSNk0SU9kFlSaqmQM9sFXW8Mny7P5bvJoNb/fGjnJ3o7BmSjwu3ow== -"@swc/html-win32-ia32-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.11.29.tgz#61c91409c3fcdf942891c02aa2a2eac1892d1907" - integrity sha512-rO6X4qOofGpKV8pyZ7VblJn+J3PHEqeWHJkJfzwP7c04Flr1oLyuLbTU8lwf8enXrTAZqitHZs+OpofKcUwHEw== +"@swc/html-win32-ia32-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.12.6.tgz#aaa89f639059c1263855c571d6678ca8c5ce8e8a" + integrity sha512-4PQysHukXaGUbP9af6DdqEIuNHMShUj5xQrVZ9M/JNV77JuX8RhTTc8Nq4IzGvCepS77gJnKg2nUbKEOt0vHaQ== -"@swc/html-win32-x64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.11.29.tgz#0e78b507d2bf28315487655852008cdecfe84535" - integrity sha512-GSCihzBItEPJAeLzkAtw0ZGbxRGMsGt1Z1ugo0uHva1R3Eybkqu9qoax1tGAON+EJzeiHRqphhNgh8MVDpnKnQ== +"@swc/html-win32-x64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.12.6.tgz#632091341d39231c813fab1beabab4408d6ffe33" + integrity sha512-dNg1qIzriAUQkSwWQP+b7GK09zU126VYt9Eng4RlLzdvZYO1EWrnvTLGgvAADyLk8ELvrDUJ1joaaKzFzZXVOQ== "@swc/html@^1.7.39": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.11.29.tgz#6e9e1b8ea65baa0d6f25cb883565a5e7d22d2858" - integrity sha512-Tsk/o6Eo3lDvHPGjLqVwXGEdC1bemGzByPWx/TrF5N7qEsanRblPeRcJzLl6LbWa80pRYIRB6T4VqdXXZqklaw== + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.12.6.tgz#faf01ad0594287680bdb495b424da0f232f81216" + integrity sha512-Qki6Ci6f16BWJhEz5gNB/2QAsSIYvvIjLYUNsrmo1P//By7SF42oDZcu7jPLpsdlMK+qGH9n37be+HZFj9Zn5w== dependencies: "@swc/counter" "^0.1.3" optionalDependencies: - "@swc/html-darwin-arm64" "1.11.29" - "@swc/html-darwin-x64" "1.11.29" - "@swc/html-linux-arm-gnueabihf" "1.11.29" - "@swc/html-linux-arm64-gnu" "1.11.29" - "@swc/html-linux-arm64-musl" "1.11.29" - "@swc/html-linux-x64-gnu" "1.11.29" - "@swc/html-linux-x64-musl" "1.11.29" - "@swc/html-win32-arm64-msvc" "1.11.29" - "@swc/html-win32-ia32-msvc" "1.11.29" - "@swc/html-win32-x64-msvc" "1.11.29" + "@swc/html-darwin-arm64" "1.12.6" + "@swc/html-darwin-x64" "1.12.6" + "@swc/html-linux-arm-gnueabihf" "1.12.6" + "@swc/html-linux-arm64-gnu" "1.12.6" + "@swc/html-linux-arm64-musl" "1.12.6" + "@swc/html-linux-x64-gnu" "1.12.6" + "@swc/html-linux-x64-musl" "1.12.6" + "@swc/html-win32-arm64-msvc" "1.12.6" + "@swc/html-win32-ia32-msvc" "1.12.6" + "@swc/html-win32-x64-msvc" "1.12.6" -"@swc/types@^0.1.21": - version "0.1.21" - resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.21.tgz#6fcadbeca1d8bc89e1ab3de4948cef12344a38c0" - integrity sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ== +"@swc/types@^0.1.23": + version "0.1.23" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.23.tgz#7eabf88b9cfd929253859c562ae95982ee04b4e8" + integrity sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw== dependencies: "@swc/counter" "^0.1.3" @@ -3847,6 +4534,15 @@ dependencies: defer-to-connect "^2.0.1" +"@tanem/svg-injector@^10.1.68": + version "10.1.68" + resolved "https://registry.yarnpkg.com/@tanem/svg-injector/-/svg-injector-10.1.68.tgz#0bd08da3c4184b055a6fe16909037c96f49e3cd1" + integrity sha512-UkJajeR44u73ujtr5GVSbIlELDWD/mzjqWe54YMK61ljKxFcJoPd9RBSaO7xj02ISCWUqJW99GjrS+sVF0UnrA== + dependencies: + "@babel/runtime" "^7.23.2" + content-type "^1.0.5" + tslib "^2.6.2" + "@tanstack/react-virtual@^3.0.0-beta.60": version "3.5.1" resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.5.1.tgz#1ce466f530a10f781871360ed2bf7ff83e664f85" @@ -4147,9 +4843,9 @@ integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== "@types/estree@^1.0.6": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" - integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.33": version "4.19.3" @@ -4283,6 +4979,14 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== +"@types/node-fetch@^2.6.4": + version "2.6.12" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03" + integrity sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node-forge@^1.3.0": version "1.3.11" resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" @@ -4302,6 +5006,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== +"@types/node@^18.11.18": + version "18.19.86" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.86.tgz#a7e1785289c343155578b9d84a0e3e924deb948b" + integrity sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ== + dependencies: + undici-types "~5.26.4" + "@types/parse5@^6.0.0": version "6.0.3" resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb" @@ -4317,6 +5028,11 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== +"@types/prop-types@^15.7.14": + version "15.7.14" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.14.tgz#1433419d73b2a7ebfc6918dcefd2ec0d5cd698f2" + integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== + "@types/qs@*": version "6.9.15" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.15.tgz#adde8a060ec9c305a82de1babc1056e73bd64dce" @@ -4712,6 +5428,87 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@zag-js/core@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/core/-/core-1.17.1.tgz#1d47e8117352cb42b3de0dd2672189a8e0bf955b" + integrity sha512-68jh6R87QLMYrtntu34eSF9JJXRXd+/l5Mpaz/InEOwA9sjxuyJIESqO578IpI2GAqk+cE1sUTKhhPmkzeTq3g== + dependencies: + "@zag-js/dom-query" "1.17.1" + "@zag-js/utils" "1.17.1" + +"@zag-js/dom-query@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@zag-js/dom-query/-/dom-query-1.10.0.tgz#62d5cdb887297c7522bde3e86ddb67cedf1cfad2" + integrity sha512-UQM4pHPPwpPNyuIcaDvuTjI4ntvBCV0oatpd+OcOW8NdUc2VVcPzL4cN6q1h+Q9s0Rpi+q77X0x6t9c1QWj1Iw== + dependencies: + "@zag-js/types" "1.10.0" + +"@zag-js/dom-query@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/dom-query/-/dom-query-1.17.1.tgz#38a8496869fb4fd1e02b6734d5f59e52d17bfc71" + integrity sha512-fwwzEKLPq3kAZVkkPBdskL4Ge4aHRAGqBLfAHCKioQNgvKYGRTzqmGA6ijls9ESULUWf0M2ogKstuUtY19PopA== + dependencies: + "@zag-js/types" "1.17.1" + +"@zag-js/focus-trap@^1.7.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@zag-js/focus-trap/-/focus-trap-1.10.0.tgz#c292010997ce09581aeb1729f9151a80aa4cf141" + integrity sha512-6+SPzXws7BurUb5AxHD6RoygInvPkGhleJmClQadeFhOlOdZdaeqwZjnoA3WoH/15V4NfUnoIzy72Su36D8RmA== + dependencies: + "@zag-js/dom-query" "1.10.0" + +"@zag-js/presence@^1.13.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/presence/-/presence-1.17.1.tgz#9fee698db453fa49c743a175910b2ba107a9bed5" + integrity sha512-2b9/4gs/ZuTpplqNjTARWjEgqkV8pMjcrH5u/fFng2cm5JRhcPrgWDSeOiahKOCdWj8x+f5EkNVvBOqs4Bmcsw== + dependencies: + "@zag-js/core" "1.17.1" + "@zag-js/dom-query" "1.17.1" + "@zag-js/types" "1.17.1" + +"@zag-js/react@^1.13.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/react/-/react-1.17.1.tgz#917f6fd739a9e54e73578f03813e2de96cc919c2" + integrity sha512-hgIpkHpfJByWMtaBvrJQNxBsEghFDWDWRx/JcG5cv+0VDS3bdT2U6b4AWRq6/6CMI1a2bXodgxXrgXj0t1UofQ== + dependencies: + "@zag-js/core" "1.17.1" + "@zag-js/store" "1.17.1" + "@zag-js/types" "1.17.1" + "@zag-js/utils" "1.17.1" + +"@zag-js/store@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/store/-/store-1.17.1.tgz#d7833045c56169f028324d64b8fd3dc2f78df22a" + integrity sha512-01iHhN08QezWTgouaAQdOW/WQUieTBv3Abl3QeGPtQ1UC8oygG84zea1uF+FzqxhT/KtWvI2AT0zRaw368aqVQ== + dependencies: + proxy-compare "3.0.1" + +"@zag-js/types@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@zag-js/types/-/types-1.10.0.tgz#d6f0d406d06cc954622b0234d2c2aeab64999ffd" + integrity sha512-HlM+EHYPLPaHgmuf2Bg5isNy2Kv30nwaANbkcMhVQYi8OfrTraxUQbTDXk3hb56qFmW1HQCMZzt1L7aS2qlOyQ== + dependencies: + csstype "3.1.3" + +"@zag-js/types@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/types/-/types-1.17.1.tgz#ce75409a9a89431f790038fd145cc9353d5fa236" + integrity sha512-KEPko1DK19hEMfM5IPKTZQtpf4HC3X56qwckezRX1yk+/vGotVUxdjRIrv+pcITjlFAoQQO9TiiZv2UiiVrFGA== + dependencies: + csstype "3.1.3" + +"@zag-js/utils@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/utils/-/utils-1.17.1.tgz#0015f9a160877672a75a2ed0419c289fb5fcb22d" + integrity sha512-+w/Kx7uZufg3cD6I5bQ8iSoeY3qSarPpUwrxz6FCOxJ86IAmf3ActqFC2pJ6DQCdHdkWINaKKchb4GNt8ld7KQ== + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -4743,9 +5540,9 @@ acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.7.1, acorn@^8.8.2: integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw== acorn@^8.14.0: - version "8.14.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" - integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== address@^1.0.1: version "1.2.2" @@ -4759,6 +5556,13 @@ agent-base@6: dependencies: debug "4" +agentkeepalive@^4.2.1: + version "4.6.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== + dependencies: + humanize-ms "^1.2.1" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -4822,30 +5626,30 @@ ajv@^8.0.0, ajv@^8.9.0: uri-js "^4.4.1" algoliasearch-helper@^3.22.6: - version "3.25.0" - resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.25.0.tgz#15cc79ad7909db66b8bb5a5a9c38b40e3941fa2f" - integrity sha512-vQoK43U6HXA9/euCqLjvyNdM4G2Fiu/VFp4ae0Gau9sZeIKBPvUPnXfLYAe65Bg7PFuw03coeu5K6lTPSXRObw== + version "3.26.0" + resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.26.0.tgz#d6e283396a9fc5bf944f365dc3b712570314363f" + integrity sha512-Rv2x3GXleQ3ygwhkhJubhhYGsICmShLAiqtUuJTUkr9uOCOXyF2E71LVT4XDnVffbknv8XgScP4U0Oxtgm+hIw== dependencies: "@algolia/events" "^4.0.1" algoliasearch@^5.14.2, algoliasearch@^5.17.1: - version "5.25.0" - resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.25.0.tgz#7337b097deadeca0e6e985c0f8724abea189994f" - integrity sha512-n73BVorL4HIwKlfJKb4SEzAYkR3Buwfwbh+MYxg2mloFph2fFGV58E90QTzdbfzWrLn4HE5Czx/WTjI8fcHaMg== + version "5.29.0" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.29.0.tgz#0feae8e0a71fced857be4e97c434ef9dce89783b" + integrity sha512-E2l6AlTWGznM2e7vEE6T6hzObvEyXukxMOlBmVlMyixZyK1umuO/CiVc6sDBbzVH0oEviCE5IfVY1oZBmccYPQ== dependencies: - "@algolia/client-abtesting" "5.25.0" - "@algolia/client-analytics" "5.25.0" - "@algolia/client-common" "5.25.0" - "@algolia/client-insights" "5.25.0" - "@algolia/client-personalization" "5.25.0" - "@algolia/client-query-suggestions" "5.25.0" - "@algolia/client-search" "5.25.0" - "@algolia/ingestion" "1.25.0" - "@algolia/monitoring" "1.25.0" - "@algolia/recommend" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-abtesting" "5.29.0" + "@algolia/client-analytics" "5.29.0" + "@algolia/client-common" "5.29.0" + "@algolia/client-insights" "5.29.0" + "@algolia/client-personalization" "5.29.0" + "@algolia/client-query-suggestions" "5.29.0" + "@algolia/client-search" "5.29.0" + "@algolia/ingestion" "1.29.0" + "@algolia/monitoring" "1.29.0" + "@algolia/recommend" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" allof-merge@^0.6.6: version "0.6.6" @@ -4854,6 +5658,11 @@ allof-merge@^0.6.6: dependencies: json-crawl "^0.5.3" +altcha-lib@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/altcha-lib/-/altcha-lib-1.2.0.tgz#a8b874ace261751473686adc5cc210be7449ba0d" + integrity sha512-S5WF8QLNRaM1hvK24XPhOLfu9is2EBCvH7+nv50sM5CaIdUCqQCd0WV/qm/ZZFGTdSoKLuDp+IapZxBLvC+SNg== + ansi-align@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" @@ -4932,6 +5741,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-hidden@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522" + integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A== + dependencies: + tslib "^2.0.0" + array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" @@ -4957,6 +5773,11 @@ async@3.2.4: resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + at-least-node@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" @@ -5053,6 +5874,11 @@ batch@0.6.1: resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== +bcp-47-match@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/bcp-47-match/-/bcp-47-match-2.0.3.tgz#603226f6e5d3914a581408be33b28a53144b09d0" + integrity sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ== + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -5312,10 +6138,15 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001599, caniuse-lite@^1.0.30001629: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz" integrity sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA== -caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001716, caniuse-lite@^1.0.30001718: - version "1.0.30001718" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz#dae13a9c80d517c30c6197515a96131c194d8f82" - integrity sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw== +caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001718: + version "1.0.30001724" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz#312e163553dd70d2c0fb603d74810c85d8ed94a0" + integrity sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA== + +caniuse-lite@^1.0.30001716: + version "1.0.30001726" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz#a15bd87d5a4bf01f6b6f70ae7c97fdfd28b5ae47" + integrity sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw== ccount@^2.0.0: version "2.0.1" @@ -5455,6 +6286,13 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +class-variance-authority@0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz#4008a798a0e4553a781a57ac5177c9fb5d043787" + integrity sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg== + dependencies: + clsx "^2.1.1" + clean-css@^5.2.2, clean-css@^5.3.3, clean-css@~5.3.2: version "5.3.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd" @@ -5504,16 +6342,16 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +clsx@2.1.1, clsx@^2.0.0, clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + clsx@^1.1.1, clsx@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== -clsx@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" - integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== - collapse-white-space@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-2.1.0.tgz#640257174f9f42c740b40f3b55ee752924feefca" @@ -5558,11 +6396,23 @@ colorette@^2.0.10: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colorjs.io@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/colorjs.io/-/colorjs.io-0.5.2.tgz#63b20139b007591ebc3359932bef84628eb3fcef" + integrity sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw== + combine-promises@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/combine-promises/-/combine-promises-1.2.0.tgz#5f2e68451862acf85761ded4d9e2af7769c2ca6a" integrity sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" @@ -5698,7 +6548,7 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4, content-type@~1.0.5: +content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -5750,11 +6600,11 @@ core-js-compat@^3.31.0, core-js-compat@^3.36.1: browserslist "^4.23.0" core-js-compat@^3.40.0: - version "3.42.0" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.42.0.tgz#ce19c29706ee5806e26d3cb3c542d4cfc0ed51bb" - integrity sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ== + version "3.43.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.43.0.tgz#055587369c458795ef316f65e0aabb808fb15840" + integrity sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA== dependencies: - browserslist "^4.24.4" + browserslist "^4.25.0" core-js-pure@^3.30.2: version "3.37.1" @@ -5897,6 +6747,11 @@ css-select@^5.1.0: domutils "^3.0.1" nth-check "^2.0.1" +css-selector-parser@^3.0.0: + version "3.1.3" + resolved "https://registry.yarnpkg.com/css-selector-parser/-/css-selector-parser-3.1.3.tgz#fb1ba303cfa00e0a7b7a49ede46c12e1b87a081f" + integrity sha512-gJMigczVZqYAk0hPVzx/M4Hm1D9QOtqkdQk9005TNzDIUGzo5cnHEDiKUT7jGPximL/oYb+LIitcHFQ4aKupxg== + css-tree@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" @@ -5919,9 +6774,9 @@ css-what@^6.0.1, css-what@^6.1.0: integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== cssdb@^8.3.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-8.3.0.tgz#940becad497b8509ad822a28fb0cfe54c969ccfe" - integrity sha512-c7bmItIg38DgGjSwDPZOYF/2o0QU/sSgkWOMyl8votOfgFuyiFKWPesmCGEsrGLxEA9uL540cp8LdaGEjUGsZQ== + version "8.3.1" + resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-8.3.1.tgz#0ac96395b7092ffee14563e948cf43c2019b051e" + integrity sha512-XnDRQMXucLueX92yDe0LPKupXetWoFOgawr4O4X41l5TltgK2NVbJJVDnnOywDYfW1sTJ28AcXGKOqdRKwCcmQ== cssesc@^3.0.0: version "3.0.0" @@ -5997,7 +6852,7 @@ csso@^5.0.5: dependencies: css-tree "~2.2.0" -csstype@^3.0.2: +csstype@3.1.3, csstype@^3.0.2: version "3.1.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== @@ -6404,6 +7259,11 @@ delaunator@5: dependencies: robust-predicates "^3.0.2" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -6414,7 +7274,7 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== -dequal@^2.0.0: +dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -6434,6 +7294,11 @@ detect-libc@^2.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8" integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA== +detect-node-es@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" + integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== + detect-node@^2.0.4: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" @@ -6476,6 +7341,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +direction@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/direction/-/direction-2.0.1.tgz#71800dd3c4fa102406502905d3866e65bdebb985" + integrity sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA== + dlv@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" @@ -6601,7 +7471,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@^3.2.4: +dompurify@^3.2.5: version "3.2.6" resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad" integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ== @@ -6671,9 +7541,9 @@ electron-to-chromium@^1.4.796: integrity sha512-61H9mLzGOCLLVsnLiRzCbc63uldP0AniRYPV3hbGVtONA1pI7qSGILdbofR7A8TMbOypDocEAjH/e+9k1QIe3g== electron-to-chromium@^1.5.149: - version "1.5.158" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.158.tgz#e5f01fc7fdf810d9d223e30593e0839c306276d4" - integrity sha512-9vcp2xHhkvraY6AHw2WMi+GDSLPX42qe2xjYaVoZqFRJiOcilVQFq9mZmpuHEQpzlgGDelKlV7ZiGcmMsc8WxQ== + version "1.5.178" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.178.tgz#6fc4d69eb5275bb13068931448fd822458901fbb" + integrity sha512-wObbz/ar3Bc6e4X5vf0iO8xTN8YAjN/tgiAOJLr7yjYFtP9wAjq8Mb5h0yn6kResir+VYx2DXBj9NNobs0ETSA== electron-to-chromium@^1.5.160: version "1.5.172" @@ -6726,9 +7596,9 @@ enhanced-resolve@^5.17.0: tapable "^2.2.0" enhanced-resolve@^5.17.1: - version "5.18.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf" - integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== + version "5.18.2" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz#7903c5b32ffd4b2143eeb4b92472bd68effd5464" + integrity sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -6779,6 +7649,16 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: dependencies: es-errors "^1.3.0" +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + es6-promise@^3.2.1: version "3.3.1" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" @@ -6955,6 +7835,11 @@ eval@^0.1.8: "@types/node" "*" require-like ">= 0.1.1" +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.0, eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" @@ -7023,9 +7908,9 @@ express@^4.17.3: vary "~1.1.2" exsolve@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.5.tgz#1f5b6b4fe82ad6b28a173ccb955a635d77859dcf" - integrity sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg== + version "1.0.7" + resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.7.tgz#3b74e4c7ca5c5f9a19c3626ca857309fa99f9e9e" + integrity sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw== extend-shallow@^2.0.1: version "2.0.1" @@ -7200,16 +8085,39 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" +form-data-encoder@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + form-data-encoder@^2.1.2: version "2.1.4" resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== +form-data@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c" + integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + mime-types "^2.1.12" + format@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww== +formdata-node@^4.3.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -7230,6 +8138,15 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== +fs-extra@^11.0.0: + version "11.3.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d" + integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-extra@^11.1.1, fs-extra@^11.2.0: version "11.2.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" @@ -7290,7 +8207,7 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: has-symbols "^1.0.3" hasown "^2.0.0" -get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -7306,6 +8223,11 @@ get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: hasown "^2.0.2" math-intrinsics "^1.1.0" +get-nonce@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" + integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -7523,6 +8445,13 @@ has-symbols@^1.1.0: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + has-yarn@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-3.0.0.tgz#c3c21e559730d1d3b57e28af1f30d06fac38147d" @@ -7535,6 +8464,14 @@ hasown@^2.0.0, hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hast-util-embedded@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz#be4477780fbbe079cdba22982e357a0de4ba853e" + integrity sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA== + dependencies: + "@types/hast" "^3.0.0" + hast-util-is-element "^3.0.0" + hast-util-from-html@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/hast-util-from-html/-/hast-util-from-html-1.0.2.tgz#2482fd701b2d8270b912b3909d6fb645d4a346cf" @@ -7546,6 +8483,18 @@ hast-util-from-html@^1.0.1: vfile "^5.0.0" vfile-message "^3.0.0" +hast-util-from-html@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz#485c74785358beb80c4ba6346299311ac4c49c82" + integrity sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw== + dependencies: + "@types/hast" "^3.0.0" + devlop "^1.1.0" + hast-util-from-parse5 "^8.0.0" + parse5 "^7.0.0" + vfile "^6.0.0" + vfile-message "^4.0.0" + hast-util-from-parse5@^7.0.0: version "7.1.2" resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-7.1.2.tgz#aecfef73e3ceafdfa4550716443e4eb7b02e22b0" @@ -7573,6 +8522,38 @@ hast-util-from-parse5@^8.0.0: vfile-location "^5.0.0" web-namespaces "^2.0.0" +hast-util-has-property@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz#4e595e3cddb8ce530ea92f6fc4111a818d8e7f93" + integrity sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-is-body-ok-link@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz#ef63cb2f14f04ecf775139cd92bda5026380d8b4" + integrity sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-is-element@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz#6e31a6532c217e5b533848c7e52c9d9369ca0932" + integrity sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-minify-whitespace@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz#7588fd1a53f48f1d30406b81959dffc3650daf55" + integrity sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw== + dependencies: + "@types/hast" "^3.0.0" + hast-util-embedded "^3.0.0" + hast-util-is-element "^3.0.0" + hast-util-whitespace "^3.0.0" + unist-util-is "^6.0.0" + hast-util-parse-selector@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz#25ab00ae9e75cbc62cf7a901f68a247eade659e2" @@ -7587,6 +8568,17 @@ hast-util-parse-selector@^4.0.0: dependencies: "@types/hast" "^3.0.0" +hast-util-phrasing@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz#fa284c0cd4a82a0dd6020de8300a7b1ebffa1690" + integrity sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ== + dependencies: + "@types/hast" "^3.0.0" + hast-util-embedded "^3.0.0" + hast-util-has-property "^3.0.0" + hast-util-is-body-ok-link "^3.0.0" + hast-util-is-element "^3.0.0" + hast-util-raw@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/hast-util-raw/-/hast-util-raw-7.2.3.tgz#dcb5b22a22073436dbdc4aa09660a644f4991d99" @@ -7623,6 +8615,27 @@ hast-util-raw@^9.0.0: web-namespaces "^2.0.0" zwitch "^2.0.0" +hast-util-select@^6.0.4: + version "6.0.4" + resolved "https://registry.yarnpkg.com/hast-util-select/-/hast-util-select-6.0.4.tgz#1d8f69657a57441d0ce0ade35887874d3e65a303" + integrity sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + bcp-47-match "^2.0.0" + comma-separated-tokens "^2.0.0" + css-selector-parser "^3.0.0" + devlop "^1.0.0" + direction "^2.0.0" + hast-util-has-property "^3.0.0" + hast-util-to-string "^3.0.0" + hast-util-whitespace "^3.0.0" + nth-check "^2.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + unist-util-visit "^5.0.0" + zwitch "^2.0.0" + hast-util-to-estree@^2.1.0: version "2.3.3" resolved "https://registry.yarnpkg.com/hast-util-to-estree/-/hast-util-to-estree-2.3.3.tgz#da60142ffe19a6296923ec222aba73339c8bf470" @@ -7666,6 +8679,23 @@ hast-util-to-estree@^3.0.0: unist-util-position "^5.0.0" zwitch "^2.0.0" +hast-util-to-html@^9.0.0, hast-util-to-html@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005" + integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + hast-util-to-jsx-runtime@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz#3ed27caf8dc175080117706bf7269404a0aa4f7c" @@ -7687,6 +8717,26 @@ hast-util-to-jsx-runtime@^2.0.0: unist-util-position "^5.0.0" vfile-message "^4.0.0" +hast-util-to-mdast@^10.0.0: + version "10.1.2" + resolved "https://registry.yarnpkg.com/hast-util-to-mdast/-/hast-util-to-mdast-10.1.2.tgz#bc76f7f5f72f2cde4d6a66ad4cd0aba82bb79909" + integrity sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + hast-util-phrasing "^3.0.0" + hast-util-to-html "^9.0.0" + hast-util-to-text "^4.0.0" + hast-util-whitespace "^3.0.0" + mdast-util-phrasing "^4.0.0" + mdast-util-to-hast "^13.0.0" + mdast-util-to-string "^4.0.0" + rehype-minify-whitespace "^6.0.0" + trim-trailing-lines "^2.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + hast-util-to-parse5@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/hast-util-to-parse5/-/hast-util-to-parse5-7.1.0.tgz#c49391bf8f151973e0c9adcd116b561e8daf29f3" @@ -7712,6 +8762,23 @@ hast-util-to-parse5@^8.0.0: web-namespaces "^2.0.0" zwitch "^2.0.0" +hast-util-to-string@^3.0.0, hast-util-to-string@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz#a4f15e682849326dd211c97129c94b0c3e76527c" + integrity sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-to-text@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz#57b676931e71bf9cb852453678495b3080bfae3e" + integrity sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + hast-util-is-element "^3.0.0" + unist-util-find-after "^5.0.0" + hast-util-whitespace@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz#0ec64e257e6fc216c7d14c8a1b74d27d650b4557" @@ -7821,6 +8888,11 @@ html-tags@^3.3.1: resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== +html-url-attributes@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87" + integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ== + html-void-elements@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-2.0.1.tgz#29459b8b05c200b6c5ee98743c41b979d577549f" @@ -7949,6 +9021,18 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== + dependencies: + ms "^2.0.0" + +humps@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa" + integrity sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g== + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -7989,9 +9073,9 @@ immer@^9.0.21: integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA== immutable@^5.0.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.2.tgz#e8169476414505e5a4fa650107b65e1227d16d4b" - integrity sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ== + version "5.1.3" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.3.tgz#e6486694c8b76c37c063cca92399fa64098634d4" + integrity sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg== import-fresh@^3.3.0: version "3.3.0" @@ -8244,6 +9328,11 @@ is-typedarray@^1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== +is-what@^4.1.8: + version "4.1.16" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" + integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== + is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" @@ -8715,6 +9804,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lucide-react@^0.503.0: + version "0.503.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.503.0.tgz#4ac55b262fa613f9497531c9df50ea0e883d2de2" + integrity sha512-HGGkdlPWQ0vTF8jJ5TdIqhQXZi6uh3LnNgfZ8MHiuxFfX3RZeA79r2MW2tHAZKlAVfoNE8esm3p+O6VkIvpj6w== + markdown-extensions@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/markdown-extensions/-/markdown-extensions-2.0.0.tgz#34bebc83e9938cae16e0e017e4a9814a8330d3c4" @@ -8732,7 +9826,7 @@ markdown-table@^3.0.0: resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd" integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw== -marked@^15.0.7: +marked@^15.0.7, marked@^15.0.9: version "15.0.12" resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.12.tgz#30722c7346e12d0a2d0207ab9b0c4f0102d86c4e" integrity sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA== @@ -9194,6 +10288,13 @@ memoize-one@^5.1.1: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== +merge-anything@5.1.7: + version "5.1.7" + resolved "https://registry.yarnpkg.com/merge-anything/-/merge-anything-5.1.7.tgz#94f364d2b0cf21ac76067b5120e429353b3525d7" + integrity sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ== + dependencies: + is-what "^4.1.8" + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -9210,13 +10311,13 @@ merge2@^1.3.0, merge2@^1.4.1: integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== mermaid@>=11.6.0: - version "11.6.0" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.6.0.tgz#eee45cdc3087be561a19faf01745596d946bb575" - integrity sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg== + version "11.7.0" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.7.0.tgz#53f319147632db15e499c5ccb72b24b277a00bae" + integrity sha512-/1/5R0rt0Z1Ak0CuznAnCF3HtQgayRXUz6SguzOwN4L+DuCobz0UxnQ+ZdTSZ3AugKVVh78tiVmsHpHWV25TCw== dependencies: "@braintree/sanitize-url" "^7.0.4" "@iconify/utils" "^2.1.33" - "@mermaid-js/parser" "^0.4.0" + "@mermaid-js/parser" "^0.5.0" "@types/d3" "^7.4.3" cytoscape "^3.29.3" cytoscape-cose-bilkent "^4.1.0" @@ -9225,7 +10326,7 @@ mermaid@>=11.6.0: d3-sankey "^0.12.3" dagre-d3-es "7.0.11" dayjs "^1.11.13" - dompurify "^3.2.4" + dompurify "^3.2.5" katex "^0.16.9" khroma "^2.1.0" lodash-es "^4.17.21" @@ -10041,7 +11142,7 @@ mime-types@2.1.31: dependencies: mime-db "1.48.0" -mime-types@2.1.35, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@2.1.35, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -10147,7 +11248,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.3: +ms@2.1.3, ms@^2.0.0, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -10207,6 +11308,11 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-emoji@^2.1.0: version "2.1.3" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-2.1.3.tgz#93cfabb5cc7c3653aa52f29d6ffb7927d8047c06" @@ -10231,7 +11337,7 @@ node-fetch@2.6.7: dependencies: whatwg-url "^5.0.0" -node-fetch@^2.6.1: +node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -10287,7 +11393,7 @@ nprogress@^0.2.0: resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" integrity sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA== -nth-check@^2.0.1: +nth-check@^2.0.0, nth-check@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== @@ -10435,6 +11541,19 @@ open@^8.0.9, open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openai@4.78.1: + version "4.78.1" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.78.1.tgz#44c3b195d239891be9c9c53722539ad8a1fcc5f2" + integrity sha512-drt0lHZBd2lMyORckOXFPQTmnGLWSLt8VK0W9BhOKWpMFBEoHMoz5gxMPmVq5icp+sOrsbMnsmZTVHUlKvD1Ow== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + openapi-to-postmanv2@^4.21.0: version "4.21.0" resolved "https://registry.yarnpkg.com/openapi-to-postmanv2/-/openapi-to-postmanv2-4.21.0.tgz#4bc5b19ccbd1514c2b3466268a7f5dd64b61f535" @@ -10690,7 +11809,7 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -path@0.12.7: +path@0.12.7, path@^0.12.7: version "0.12.7" resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f" integrity sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q== @@ -11471,6 +12590,14 @@ pretty-time@^1.1.0: resolved "https://registry.yarnpkg.com/pretty-time/-/pretty-time-1.1.0.tgz#ffb7429afabb8535c346a34e41873adf3d74dd0e" integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA== +prism-react-renderer@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz#ac63b7f78e56c8f2b5e76e823a976d5ede77e35f" + integrity sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig== + dependencies: + "@types/prismjs" "^1.26.0" + clsx "^2.0.0" + prism-react-renderer@^2.0.6, prism-react-renderer@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-2.3.1.tgz#e59e5450052ede17488f6bc85de1553f584ff8d5" @@ -11521,6 +12648,11 @@ property-information@^6.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.5.0.tgz#6212fbb52ba757e92ef4fb9d657563b933b7ffec" integrity sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig== +property-information@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" + integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -11534,6 +12666,11 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-compare@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/proxy-compare/-/proxy-compare-3.0.1.tgz#3262cff3a25a6dedeaa299f6cf2369d6f7588a94" + integrity sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q== + proxy-from-env@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -11680,6 +12817,13 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-error-boundary@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-6.0.0.tgz#a9e552146958fa77d873b587aa6a5e97544ee954" + integrity sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA== + dependencies: + "@babel/runtime" "^7.12.5" + react-fast-compare@^3.0.1, react-fast-compare@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" @@ -11701,6 +12845,11 @@ react-helmet-async@^1.3.0, "react-helmet-async@npm:@slorber/react-helmet-async@1 react-fast-compare "^3.2.0" shallowequal "^1.1.0" +react-hook-form@7.54.2: + version "7.54.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.54.2.tgz#8c26ed54c71628dff57ccd3c074b1dd377cfb211" + integrity sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg== + react-hook-form@^7.43.8: version "7.52.0" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.52.0.tgz#e52b33043e283719586b9dd80f6d51b68dd3999c" @@ -11759,6 +12908,22 @@ react-magic-dropzone@^1.0.1: resolved "https://registry.yarnpkg.com/react-magic-dropzone/-/react-magic-dropzone-1.0.1.tgz#bfd25b77b57e7a04aaef0a28910563b707ee54df" integrity sha512-0BIROPARmXHpk4AS3eWBOsewxoM5ndk2psYP/JmbCq8tz3uR2LIV1XiroZ9PKrmDRMctpW+TvsBCtWasuS8vFA== +react-markdown@9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.0.3.tgz#c12bf60dad05e9bf650b86bcc612d80636e8456e" + integrity sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw== + dependencies: + "@types/hast" "^3.0.0" + devlop "^1.0.0" + hast-util-to-jsx-runtime "^2.0.0" + html-url-attributes "^3.0.0" + mdast-util-to-hast "^13.0.0" + remark-parse "^11.0.0" + remark-rehype "^11.0.0" + unified "^11.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + react-markdown@^8.0.1: version "8.0.7" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-8.0.7.tgz#c8dbd1b9ba5f1c5e7e5f2a44de465a3caafdf89b" @@ -11813,6 +12978,36 @@ react-redux@^7.2.0: prop-types "^15.7.2" react-is "^17.0.2" +react-remove-scroll-bar@^2.3.7: + version "2.3.8" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223" + integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q== + dependencies: + react-style-singleton "^2.2.2" + tslib "^2.0.0" + +react-remove-scroll@^2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz#df02cde56d5f2731e058531f8ffd7f9adec91ac2" + integrity sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" + +react-remove-scroll@^2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz#d2101d414f6d81d7d3bf033f3c1cb4785789f753" + integrity sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" + react-router-config@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" @@ -11848,6 +13043,33 @@ react-router@5.3.4, react-router@^5.3.4: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" + integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ== + dependencies: + get-nonce "^1.0.0" + tslib "^2.0.0" + +react-svg@16.3.0: + version "16.3.0" + resolved "https://registry.yarnpkg.com/react-svg/-/react-svg-16.3.0.tgz#de7a4bb6ee2d465c1ff7125ec27414ac27e907d7" + integrity sha512-MvoQbITgkmpPJYwDTNdiUyoncJFfoa0D86WzoZuMQ9c/ORJURPR6rPMnXDsLOWDCAyXuV9nKZhQhGyP0HZ0MVQ== + dependencies: + "@babel/runtime" "^7.26.0" + "@tanem/svg-injector" "^10.1.68" + "@types/prop-types" "^15.7.14" + prop-types "^15.8.1" + +react-textarea-autosize@8.5.7: + version "8.5.7" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.7.tgz#b2bf1913383a05ffef7fbc89c2ea21ba8133b023" + integrity sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ== + dependencies: + "@babel/runtime" "^7.20.13" + use-composed-ref "^1.3.0" + use-latest "^1.2.1" + react@^18.2.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" @@ -12008,6 +13230,32 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" +rehype-minify-whitespace@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/rehype-minify-whitespace/-/rehype-minify-whitespace-6.0.2.tgz#7dd234ce0775656ce6b6b0aad0a6093de29b2278" + integrity sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw== + dependencies: + "@types/hast" "^3.0.0" + hast-util-minify-whitespace "^1.0.0" + +rehype-parse@^9: + version "9.0.1" + resolved "https://registry.yarnpkg.com/rehype-parse/-/rehype-parse-9.0.1.tgz#9993bda129acc64c417a9d3654a7be38b2a94c20" + integrity sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag== + dependencies: + "@types/hast" "^3.0.0" + hast-util-from-html "^2.0.0" + unified "^11.0.0" + +rehype-raw@7.0.0, rehype-raw@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4" + integrity sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww== + dependencies: + "@types/hast" "^3.0.0" + hast-util-raw "^9.0.0" + vfile "^6.0.0" + rehype-raw@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-6.1.1.tgz#81bbef3793bd7abacc6bf8335879d1b6c868c9d4" @@ -12017,13 +13265,15 @@ rehype-raw@^6.1.1: hast-util-raw "^7.2.0" unified "^10.0.0" -rehype-raw@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4" - integrity sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww== +rehype-remark@^10: + version "10.0.1" + resolved "https://registry.yarnpkg.com/rehype-remark/-/rehype-remark-10.0.1.tgz#f669fa68cfb8b5baaf4fa95476a923516111a43b" + integrity sha512-EmDndlb5NVwXGfUa4c9GPK+lXeItTilLhE6ADSaQuHr4JUlKw9MidzGzx4HpqZrNCt6vnHmEifXQiiA+CEnjYQ== dependencies: "@types/hast" "^3.0.0" - hast-util-raw "^9.0.0" + "@types/mdast" "^4.0.0" + hast-util-to-mdast "^10.0.0" + unified "^11.0.0" vfile "^6.0.0" relateurl@^0.2.7: @@ -12072,6 +13322,18 @@ remark-gfm@3.0.1: micromark-extension-gfm "^2.0.0" unified "^10.0.0" +remark-gfm@^4, remark-gfm@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b" + integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-gfm "^3.0.0" + micromark-extension-gfm "^3.0.0" + remark-parse "^11.0.0" + remark-stringify "^11.0.0" + unified "^11.0.0" + remark-gfm@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.0.tgz#aea777f0744701aa288b67d28c43565c7e8c35de" @@ -12132,7 +13394,7 @@ remark-rehype@^11.0.0: unified "^11.0.0" vfile "^6.0.0" -remark-stringify@^11.0.0: +remark-stringify@^11, remark-stringify@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3" integrity sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw== @@ -12304,9 +13566,9 @@ sass-loader@^16.0.2: neo-async "^2.6.2" sass@^1.80.4: - version "1.89.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.0.tgz#6df72360c5c3ec2a9833c49adafe57b28206752d" - integrity sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ== + version "1.89.2" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.2.tgz#a771716aeae774e2b529f72c0ff2dfd46c9de10e" + integrity sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA== dependencies: chokidar "^4.0.0" immutable "^5.0.2" @@ -12805,7 +14067,7 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@^5.0.1, string-width@^5.1.2: +string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== @@ -12994,6 +14256,11 @@ swc-loader@^0.2.6: dependencies: "@swc/counter" "^0.1.3" +tailwind-merge@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5" + integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA== + tailwindcss@^3.2.4: version "3.4.4" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.4.tgz#351d932273e6abfa75ce7d226b5bf3a6cb257c05" @@ -13081,9 +14348,9 @@ terser@^5.10.0, terser@^5.15.1, terser@^5.26.0: source-map-support "~0.5.20" terser@^5.31.1: - version "5.40.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.40.0.tgz#839a80db42bfee8340085f44ea99b5cba36c55c8" - integrity sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA== + version "5.43.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.43.1.tgz#88387f4f9794ff1a29e7ad61fb2932e25b4fdb6d" + integrity sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.14.0" @@ -13130,9 +14397,9 @@ tinyexec@^1.0.1: integrity sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw== tinypool@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.2.tgz#706193cc532f4c100f66aa00b01c42173d9051b2" - integrity sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA== + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== to-fast-properties@^2.0.0: version "2.0.0" @@ -13176,6 +14443,11 @@ trim-lines@^3.0.0: resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== +trim-trailing-lines@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-2.1.0.tgz#9aac7e89b09cb35badf663de7133c6de164f86df" + integrity sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg== + trough@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" @@ -13191,7 +14463,12 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -tslib@^2.0.3, tslib@^2.6.0: +tslib@^2.0.0, tslib@^2.6.2: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +tslib@^2.0.3, tslib@^2.1.0, tslib@^2.6.0: version "2.6.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== @@ -13285,6 +14562,19 @@ unified@^10.0.0: trough "^2.0.0" vfile "^5.0.0" +unified@^11: + version "11.0.5" + resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1" + integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA== + dependencies: + "@types/unist" "^3.0.0" + bail "^2.0.0" + devlop "^1.0.0" + extend "^3.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^6.0.0" + unified@^11.0.0, unified@^11.0.3, unified@^11.0.4: version "11.0.4" resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.4.tgz#f4be0ac0fe4c88cb873687c07c64c49ed5969015" @@ -13305,6 +14595,14 @@ unique-string@^3.0.0: dependencies: crypto-random-string "^4.0.0" +unist-util-find-after@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz#3fccc1b086b56f34c8b798e1ff90b5c54468e896" + integrity sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-generated@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unist-util-generated/-/unist-util-generated-2.0.1.tgz#e37c50af35d3ed185ac6ceacb6ca0afb28a85cae" @@ -13407,7 +14705,7 @@ unist-util-visit@^4.0.0: unist-util-is "^5.0.0" unist-util-visit-parents "^5.1.1" -unist-util-visit@^5.0.0: +unist-util-visit@^5, unist-util-visit@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg== @@ -13486,11 +14784,48 @@ url@^0.11.1: punycode "^1.4.1" qs "^6.12.3" +use-callback-ref@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf" + integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg== + dependencies: + tslib "^2.0.0" + +use-composed-ref@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.4.0.tgz#09e023bf798d005286ad85cd20674bdf5770653b" + integrity sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w== + use-editable@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/use-editable/-/use-editable-2.3.3.tgz#a292fe9ba4c291cd28d1cc2728c75a5fc8d9a33f" integrity sha512-7wVD2JbfAFJ3DK0vITvXBdpd9JAz5BcKAAolsnLBuBn6UDDwBGuCIAGvR3yA2BNKm578vAMVHFCWaOcA+BhhiA== +use-isomorphic-layout-effect@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz#afb292eb284c39219e8cb8d3d62d71999361a21d" + integrity sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w== + +use-latest@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.3.0.tgz#549b9b0d4c1761862072f0899c6f096eb379137a" + integrity sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ== + dependencies: + use-isomorphic-layout-effect "^1.1.1" + +use-sidecar@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb" + integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ== + dependencies: + detect-node-es "^1.1.0" + tslib "^2.0.0" + +use-sync-external-store@^1.4.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" + integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -13699,6 +15034,11 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -14049,7 +15389,7 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -zwitch@^2.0.0: +zwitch@^2.0.0, zwitch@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== diff --git a/e2e/config/host.docker.internal/zitadel.yaml b/e2e/config/host.docker.internal/zitadel.yaml index 203dd16437..23f35302b4 100644 --- a/e2e/config/host.docker.internal/zitadel.yaml +++ b/e2e/config/host.docker.internal/zitadel.yaml @@ -60,6 +60,9 @@ Projections: DefaultInstance: LoginPolicy: MfaInitSkipLifetime: "0" + Features: + LoginV2: + Required: false SystemAPIUsers: - cypress: diff --git a/e2e/config/localhost/zitadel.yaml b/e2e/config/localhost/zitadel.yaml index 966bb4f6b7..701e7b806b 100644 --- a/e2e/config/localhost/zitadel.yaml +++ b/e2e/config/localhost/zitadel.yaml @@ -52,6 +52,9 @@ Quotas: DefaultInstance: LoginPolicy: MfaInitSkipLifetime: "0" + Features: + LoginV2: + Required: false SystemAPIUsers: - cypress: diff --git a/go.mod b/go.mod index 9d02050b48..22980acfaf 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ toolchain go1.24.1 require ( cloud.google.com/go/profiler v0.4.2 cloud.google.com/go/storage v1.54.0 + connectrpc.com/connect v1.18.1 + connectrpc.com/grpcreflect v1.3.0 dario.cat/mergo v1.0.2 github.com/BurntSushi/toml v1.5.0 github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -29,7 +31,7 @@ require ( github.com/fatih/color v1.18.0 github.com/fergusstrange/embedded-postgres v1.30.0 github.com/gabriel-vasile/mimetype v1.4.9 - github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/chi/v5 v5.2.2 github.com/go-jose/go-jose/v4 v4.1.0 github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-webauthn/webauthn v0.10.2 @@ -67,6 +69,7 @@ require ( github.com/riverqueue/river/riverdriver v0.22.0 github.com/riverqueue/river/rivertype v0.22.0 github.com/riverqueue/rivercontrib/otelriver v0.5.0 + github.com/robfig/cron/v3 v3.0.1 github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/shopspring/decimal v1.3.1 @@ -79,7 +82,7 @@ require ( github.com/twilio/twilio-go v1.26.1 github.com/zitadel/exifremove v0.1.0 github.com/zitadel/logging v0.6.2 - github.com/zitadel/oidc/v3 v3.37.0 + github.com/zitadel/oidc/v3 v3.39.1 github.com/zitadel/passwap v0.9.0 github.com/zitadel/saml v0.3.5 github.com/zitadel/schema v1.3.1 @@ -98,8 +101,8 @@ require ( golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 golang.org/x/net v0.40.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.14.0 - golang.org/x/text v0.25.0 + golang.org/x/sync v0.15.0 + golang.org/x/text v0.26.0 google.golang.org/api v0.233.0 google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9 google.golang.org/grpc v1.72.1 @@ -126,7 +129,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-redsync/redsync/v4 v4.13.0 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/go-webauthn/x v0.1.9 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect diff --git a/go.sum b/go.sum index e2ab9768a6..7221111a2b 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,10 @@ cloud.google.com/go/storage v1.54.0 h1:Du3XEyliAiftfyW0bwfdppm2MMLdpVAfiIg4T2nAI cloud.google.com/go/storage v1.54.0/go.mod h1:hIi9Boe8cHxTyaeqh7KMMwKg088VblFK46C2x/BWaZE= cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= +connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= +connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -234,8 +238,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= -github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= @@ -279,8 +283,8 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4= github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs= github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE= @@ -806,8 +810,8 @@ github.com/zitadel/exifremove v0.1.0 h1:qD50ezWsfeeqfcvs79QyyjVfK+snN12v0U0deaU8 github.com/zitadel/exifremove v0.1.0/go.mod h1:rzKJ3woL/Rz2KthVBiSBKIBptNTvgmk9PLaeUKTm+ek= github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= -github.com/zitadel/oidc/v3 v3.37.0 h1:nYATWlnP7f18XiAbw6upUruBaqfB1kUrXrSTf1EYGO8= -github.com/zitadel/oidc/v3 v3.37.0/go.mod h1:/xDan4OUQhguJ4Ur73OOJrtugvR164OMnidXP9xfVNw= +github.com/zitadel/oidc/v3 v3.39.1 h1:6QwGwI3yxh4somT7fwRCeT1KOn/HOGv0PA0dFciwJjE= +github.com/zitadel/oidc/v3 v3.39.1/go.mod h1:aH8brOrzoliAybVdfq2xIdGvbtl0j/VsKRNa7WE72gI= github.com/zitadel/passwap v0.9.0 h1:QvDK8OHKdb73C0m+mwXvu87UJSBqix3oFwTVENHdv80= github.com/zitadel/passwap v0.9.0/go.mod h1:6QzwFjDkIr3FfudzSogTOx5Ydhq4046dRJtDM/kX+G8= github.com/zitadel/saml v0.3.5 h1:L1RKWS5y66cGepVxUGjx/WSBOtrtSpRA/J3nn5BJLOY= @@ -944,8 +948,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -988,8 +992,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= diff --git a/internal/api/api.go b/internal/api/api.go index 62d3e14b35..349e9186bc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,16 +7,18 @@ import ( "sort" "strings" + "connectrpc.com/grpcreflect" "github.com/gorilla/mux" "github.com/improbable-eng/grpc-web/go/grpcweb" "github.com/zitadel/logging" "google.golang.org/grpc" "google.golang.org/grpc/health" healthpb "google.golang.org/grpc/health/grpc_health_v1" - "google.golang.org/grpc/reflection" "github.com/zitadel/zitadel/internal/api/authz" + grpc_api "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/api/grpc/server/connect_middleware" http_util "github.com/zitadel/zitadel/internal/api/http" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/ui/login" @@ -24,10 +26,16 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/metrics" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" + system_pb "github.com/zitadel/zitadel/pkg/grpc/system" +) + +var ( + metricTypes = []metrics.MetricType{metrics.MetricTypeTotalCount, metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode} ) type API struct { port uint16 + externalDomain string grpcServer *grpc.Server verifier authz.APITokenVerifier health healthCheck @@ -37,16 +45,23 @@ type API struct { healthServer *health.Server accessInterceptor *http_mw.AccessInterceptor queries *query.Queries + authConfig authz.Config + systemAuthZ authz.Config + connectServices map[string][]string } func (a *API) ListGrpcServices() []string { serviceInfo := a.grpcServer.GetServiceInfo() - services := make([]string, len(serviceInfo)) + services := make([]string, len(serviceInfo)+len(a.connectServices)) i := 0 for servicename := range serviceInfo { services[i] = servicename i++ } + for prefix := range a.connectServices { + services[i] = strings.Trim(prefix, "/") + i++ + } sort.Strings(services) return services } @@ -59,6 +74,11 @@ func (a *API) ListGrpcMethods() []string { methods = append(methods, "/"+servicename+"/"+method.Name) } } + for service, methodList := range a.connectServices { + for _, method := range methodList { + methods = append(methods, service+method) + } + } sort.Strings(methods) return methods } @@ -82,12 +102,16 @@ func New( ) (_ *API, err error) { api := &API{ port: port, + externalDomain: externalDomain, verifier: verifier, health: queries, router: router, queries: queries, accessInterceptor: accessInterceptor, hostHeaders: hostHeaders, + authConfig: authZ, + systemAuthZ: systemAuthz, + connectServices: make(map[string][]string), } api.grpcServer = server.CreateServer(api.verifier, systemAuthz, authZ, queries, externalDomain, tlsConfig, accessInterceptor.AccessService()) @@ -100,10 +124,15 @@ func New( api.RegisterHandlerOnPrefix("/debug", api.healthHandler()) api.router.Handle("/", http.RedirectHandler(login.HandlerPrefix, http.StatusFound)) - reflection.Register(api.grpcServer) return api, nil } +func (a *API) serverReflection() { + reflector := grpcreflect.NewStaticReflector(a.ListGrpcServices()...) + a.RegisterHandlerOnPrefix(grpcreflect.NewHandlerV1(reflector)) + a.RegisterHandlerOnPrefix(grpcreflect.NewHandlerV1Alpha(reflector)) +} + // RegisterServer registers a grpc service on the grpc server, // creates a new grpc gateway and registers it as a separate http handler // @@ -131,17 +160,50 @@ func (a *API) RegisterServer(ctx context.Context, grpcServer server.WithGatewayP // and its gateway on the gateway handler // // used for >= v2 api (e.g. user, session, ...) -func (a *API) RegisterService(ctx context.Context, grpcServer server.Server) error { - grpcServer.RegisterServer(a.grpcServer) - err := server.RegisterGateway(ctx, a.grpcGateway, grpcServer) - if err != nil { - return err +func (a *API) RegisterService(ctx context.Context, srv server.Server) error { + switch service := srv.(type) { + case server.GrpcServer: + service.RegisterServer(a.grpcServer) + case server.ConnectServer: + a.registerConnectServer(service) } - a.verifier.RegisterServer(grpcServer.AppName(), grpcServer.MethodPrefix(), grpcServer.AuthMethods()) - a.healthServer.SetServingStatus(grpcServer.MethodPrefix(), healthpb.HealthCheckResponse_SERVING) + if withGateway, ok := srv.(server.WithGateway); ok { + err := server.RegisterGateway(ctx, a.grpcGateway, withGateway) + if err != nil { + return err + } + } + a.verifier.RegisterServer(srv.AppName(), srv.MethodPrefix(), srv.AuthMethods()) + a.healthServer.SetServingStatus(srv.MethodPrefix(), healthpb.HealthCheckResponse_SERVING) return nil } +func (a *API) registerConnectServer(service server.ConnectServer) { + prefix, handler := service.RegisterConnectServer( + connect_middleware.CallDurationHandler(), + connect_middleware.MetricsHandler(metricTypes, grpc_api.Probes...), + connect_middleware.NoCacheInterceptor(), + connect_middleware.InstanceInterceptor(a.queries, a.externalDomain, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName), + connect_middleware.AccessStorageInterceptor(a.accessInterceptor.AccessService()), + connect_middleware.ErrorHandler(), + connect_middleware.LimitsInterceptor(system_pb.SystemService_ServiceDesc.ServiceName), + connect_middleware.AuthorizationInterceptor(a.verifier, a.systemAuthZ, a.authConfig), + connect_middleware.TranslationHandler(), + connect_middleware.QuotaExhaustedInterceptor(a.accessInterceptor.AccessService(), system_pb.SystemService_ServiceDesc.ServiceName), + connect_middleware.ExecutionHandler(a.queries), + connect_middleware.ValidationHandler(), + connect_middleware.ServiceHandler(), + connect_middleware.ActivityInterceptor(), + ) + methods := service.FileDescriptor().Services().Get(0).Methods() + methodNames := make([]string, methods.Len()) + for i := 0; i < methods.Len(); i++ { + methodNames[i] = string(methods.Get(i).Name()) + } + a.connectServices[prefix] = methodNames + a.RegisterHandlerPrefixes(handler, prefix) +} + // HandleFunc allows registering a [http.HandlerFunc] on an exact // path, instead of prefix like RegisterHandlerOnPrefix. func (a *API) HandleFunc(path string, f http.HandlerFunc) { @@ -173,6 +235,9 @@ func (a *API) registerHealthServer() { } func (a *API) RouteGRPC() { + // since all services are now registered, we can build the grpc server reflection and register the handler + a.serverReflection() + http2Route := a.router. MatcherFunc(func(r *http.Request, _ *mux.RouteMatch) bool { return r.ProtoMajor == 2 diff --git a/internal/api/grpc/action/v2beta/execution.go b/internal/api/grpc/action/v2beta/execution.go index 5477a8128e..3b49ebb364 100644 --- a/internal/api/grpc/action/v2beta/execution.go +++ b/internal/api/grpc/action/v2beta/execution.go @@ -3,6 +3,7 @@ package action import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" @@ -13,8 +14,8 @@ import ( action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" ) -func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionRequest) (*action.SetExecutionResponse, error) { - reqTargets := req.GetTargets() +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} @@ -25,7 +26,7 @@ func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionReque var err error var details *domain.ObjectDetails instanceID := authz.GetInstance(ctx).InstanceID() - switch t := req.GetCondition().GetConditionType().(type) { + switch t := req.Msg.GetCondition().GetConditionType().(type) { case *action.Condition_Request: cond := executionConditionFromRequest(t.Request) details, err = s.command.SetExecutionRequest(ctx, cond, set, instanceID) @@ -43,27 +44,27 @@ func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionReque if err != nil { return nil, err } - return &action.SetExecutionResponse{ + return connect.NewResponse(&action.SetExecutionResponse{ SetDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) ListExecutionFunctions(ctx context.Context, _ *action.ListExecutionFunctionsRequest) (*action.ListExecutionFunctionsResponse, error) { - return &action.ListExecutionFunctionsResponse{ +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 + }), nil } -func (s *Server) ListExecutionMethods(ctx context.Context, _ *action.ListExecutionMethodsRequest) (*action.ListExecutionMethodsResponse, error) { - return &action.ListExecutionMethodsResponse{ +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 + }), nil } -func (s *Server) ListExecutionServices(ctx context.Context, _ *action.ListExecutionServicesRequest) (*action.ListExecutionServicesResponse, error) { - return &action.ListExecutionServicesResponse{ +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 + }), nil } func executionConditionFromRequest(request *action.RequestExecution) *command.ExecutionAPICondition { diff --git a/internal/api/grpc/action/v2beta/integration_test/query_test.go b/internal/api/grpc/action/v2beta/integration_test/query_test.go index 5c59bee5d1..2d74486f3e 100644 --- a/internal/api/grpc/action/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/query_test.go @@ -782,12 +782,3 @@ func TestServer_ListExecutions(t *testing.T) { }) } } - -func containExecution(t *assert.CollectT, executionList []*action.Execution, execution *action.Execution) bool { - for _, exec := range executionList { - if assert.EqualExportedValues(t, execution, exec) { - return true - } - } - return false -} diff --git a/internal/api/grpc/action/v2beta/query.go b/internal/api/grpc/action/v2beta/query.go index 1dbe80a8f7..9428b6ab7b 100644 --- a/internal/api/grpc/action/v2beta/query.go +++ b/internal/api/grpc/action/v2beta/query.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -22,14 +23,14 @@ const ( conditionIDEventGroupSegmentCount = 1 ) -func (s *Server) GetTarget(ctx context.Context, req *action.GetTargetRequest) (*action.GetTargetResponse, error) { - resp, err := s.query.GetTargetByID(ctx, req.GetId()) +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 &action.GetTargetResponse{ + return connect.NewResponse(&action.GetTargetResponse{ Target: targetToPb(resp), - }, nil + }), nil } type InstanceContext interface { @@ -41,8 +42,8 @@ type Context interface { GetOwner() InstanceContext } -func (s *Server) ListTargets(ctx context.Context, req *action.ListTargetsRequest) (*action.ListTargetsResponse, error) { - queries, err := s.ListTargetsRequestToModel(req) +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 } @@ -50,14 +51,14 @@ func (s *Server) ListTargets(ctx context.Context, req *action.ListTargetsRequest if err != nil { return nil, err } - return &action.ListTargetsResponse{ + return connect.NewResponse(&action.ListTargetsResponse{ Result: targetsToPb(resp.Targets), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } -func (s *Server) ListExecutions(ctx context.Context, req *action.ListExecutionsRequest) (*action.ListExecutionsResponse, error) { - queries, err := s.ListExecutionsRequestToModel(req) +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 } @@ -65,10 +66,10 @@ func (s *Server) ListExecutions(ctx context.Context, req *action.ListExecutionsR if err != nil { return nil, err } - return &action.ListExecutionsResponse{ + return connect.NewResponse(&action.ListExecutionsResponse{ Result: executionsToPb(resp.Executions), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } func targetsToPb(targets []*query.Target) []*action.Target { diff --git a/internal/api/grpc/action/v2beta/server.go b/internal/api/grpc/action/v2beta/server.go index ef0d8eb2ba..440bf842ca 100644 --- a/internal/api/grpc/action/v2beta/server.go +++ b/internal/api/grpc/action/v2beta/server.go @@ -1,7 +1,10 @@ package action import ( - "google.golang.org/grpc" + "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" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/query" action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/action/v2beta/actionconnect" ) -var _ action.ActionServiceServer = (*Server)(nil) +var _ actionconnect.ActionServiceHandler = (*Server)(nil) type Server struct { - action.UnimplementedActionServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -43,8 +46,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - action.RegisterActionServiceServer(grpcServer, s) +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_v2beta_action_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/action/v2beta/target.go b/internal/api/grpc/action/v2beta/target.go index 26c88b9683..b13f3461f0 100644 --- a/internal/api/grpc/action/v2beta/target.go +++ b/internal/api/grpc/action/v2beta/target.go @@ -3,6 +3,7 @@ package action import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,8 +14,8 @@ import ( action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" ) -func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetRequest) (*action.CreateTargetResponse, error) { - add := createTargetToCommand(req) +func (s *Server) CreateTarget(ctx context.Context, req *connect.Request[action.CreateTargetRequest]) (*connect.Response[action.CreateTargetResponse], error) { + add := createTargetToCommand(req.Msg) instanceID := authz.GetInstance(ctx).InstanceID() createdAt, err := s.command.AddTarget(ctx, add, instanceID) if err != nil { @@ -24,16 +25,16 @@ func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetReque if !createdAt.IsZero() { creationDate = timestamppb.New(createdAt) } - return &action.CreateTargetResponse{ + return connect.NewResponse(&action.CreateTargetResponse{ Id: add.AggregateID, CreationDate: creationDate, SigningKey: add.SigningKey, - }, nil + }), nil } -func (s *Server) UpdateTarget(ctx context.Context, req *action.UpdateTargetRequest) (*action.UpdateTargetResponse, error) { +func (s *Server) UpdateTarget(ctx context.Context, req *connect.Request[action.UpdateTargetRequest]) (*connect.Response[action.UpdateTargetResponse], error) { instanceID := authz.GetInstance(ctx).InstanceID() - update := updateTargetToCommand(req) + update := updateTargetToCommand(req.Msg) changedAt, err := s.command.ChangeTarget(ctx, update, instanceID) if err != nil { return nil, err @@ -42,15 +43,15 @@ func (s *Server) UpdateTarget(ctx context.Context, req *action.UpdateTargetReque if !changedAt.IsZero() { changeDate = timestamppb.New(changedAt) } - return &action.UpdateTargetResponse{ + return connect.NewResponse(&action.UpdateTargetResponse{ ChangeDate: changeDate, SigningKey: update.SigningKey, - }, nil + }), nil } -func (s *Server) DeleteTarget(ctx context.Context, req *action.DeleteTargetRequest) (*action.DeleteTargetResponse, error) { +func (s *Server) DeleteTarget(ctx context.Context, req *connect.Request[action.DeleteTargetRequest]) (*connect.Response[action.DeleteTargetResponse], error) { instanceID := authz.GetInstance(ctx).InstanceID() - deletedAt, err := s.command.DeleteTarget(ctx, req.GetId(), instanceID) + deletedAt, err := s.command.DeleteTarget(ctx, req.Msg.GetId(), instanceID) if err != nil { return nil, err } @@ -58,9 +59,9 @@ func (s *Server) DeleteTarget(ctx context.Context, req *action.DeleteTargetReque if !deletedAt.IsZero() { deletionDate = timestamppb.New(deletedAt) } - return &action.DeleteTargetResponse{ + return connect.NewResponse(&action.DeleteTargetResponse{ DeletionDate: deletionDate, - }, nil + }), nil } func createTargetToCommand(req *action.CreateTargetRequest) *command.AddTarget { diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index b5d36272d4..7ce2dbd7b5 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -659,7 +659,7 @@ func (s *Server) getUsers(ctx context.Context, org string, withPasswords bool, w if err != nil { return nil, nil, nil, nil, err } - metadataList, err := s.query.SearchUserMetadata(ctx, false, user.ID, &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{metadataOrgSearch}}, false) + metadataList, err := s.query.SearchUserMetadata(ctx, false, user.ID, &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{metadataOrgSearch}}, nil) metaspan.EndWithError(err) if err != nil { return nil, nil, nil, nil, err @@ -783,7 +783,7 @@ func (s *Server) getProjectsAndApps(ctx context.Context, org string) ([]*v1_pb.D if err != nil { return nil, nil, nil, nil, nil, err } - apps, err := s.query.SearchApps(ctx, &query.AppSearchQueries{Queries: []query.SearchQuery{appSearch}}, false) + apps, err := s.query.SearchApps(ctx, &query.AppSearchQueries{Queries: []query.SearchQuery{appSearch}}, nil) if err != nil { return nil, nil, nil, nil, nil, err } @@ -984,7 +984,7 @@ func (s *Server) getNecessaryUserGrantsForOrg(ctx context.Context, org string, p return nil, err } - queriedUserGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantSearchOrg}}, true) + queriedUserGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantSearchOrg}}, true, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/admin/iam_member.go b/internal/api/grpc/admin/iam_member.go index 8f9b11ce2a..301acec6d6 100644 --- a/internal/api/grpc/admin/iam_member.go +++ b/internal/api/grpc/admin/iam_member.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/member" "github.com/zitadel/zitadel/internal/api/grpc/object" admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" @@ -33,35 +34,35 @@ func (s *Server) ListIAMMembers(ctx context.Context, req *admin_pb.ListIAMMember } func (s *Server) AddIAMMember(ctx context.Context, req *admin_pb.AddIAMMemberRequest) (*admin_pb.AddIAMMemberResponse, error) { - member, err := s.command.AddInstanceMember(ctx, req.UserId, req.Roles...) + member, err := s.command.AddInstanceMember(ctx, AddIAMMemberToCommand(req, authz.GetInstance(ctx).InstanceID())) if err != nil { return nil, err } return &admin_pb.AddIAMMemberResponse{ Details: object.AddToDetailsPb( member.Sequence, - member.ChangeDate, + member.EventDate, member.ResourceOwner, ), }, nil } func (s *Server) UpdateIAMMember(ctx context.Context, req *admin_pb.UpdateIAMMemberRequest) (*admin_pb.UpdateIAMMemberResponse, error) { - member, err := s.command.ChangeInstanceMember(ctx, UpdateIAMMemberToDomain(req)) + member, err := s.command.ChangeInstanceMember(ctx, UpdateIAMMemberToCommand(req, authz.GetInstance(ctx).InstanceID())) if err != nil { return nil, err } return &admin_pb.UpdateIAMMemberResponse{ Details: object.ChangeToDetailsPb( member.Sequence, - member.ChangeDate, + member.EventDate, member.ResourceOwner, ), }, nil } func (s *Server) RemoveIAMMember(ctx context.Context, req *admin_pb.RemoveIAMMemberRequest) (*admin_pb.RemoveIAMMemberResponse, error) { - objectDetails, err := s.command.RemoveInstanceMember(ctx, req.UserId) + objectDetails, err := s.command.RemoveInstanceMember(ctx, authz.GetInstance(ctx).InstanceID(), req.UserId) if err != nil { return nil, err } diff --git a/internal/api/grpc/admin/iam_member_converter.go b/internal/api/grpc/admin/iam_member_converter.go index 2fe75214fd..695711d9cc 100644 --- a/internal/api/grpc/admin/iam_member_converter.go +++ b/internal/api/grpc/admin/iam_member_converter.go @@ -3,23 +3,25 @@ package admin import ( member_grpc "github.com/zitadel/zitadel/internal/api/grpc/member" "github.com/zitadel/zitadel/internal/api/grpc/object" - "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" member_pb "github.com/zitadel/zitadel/pkg/grpc/member" ) -func AddIAMMemberToDomain(req *admin_pb.AddIAMMemberRequest) *domain.Member { - return &domain.Member{ - UserID: req.UserId, - Roles: req.Roles, +func AddIAMMemberToCommand(req *admin_pb.AddIAMMemberRequest, instanceID string) *command.AddInstanceMember { + return &command.AddInstanceMember{ + InstanceID: instanceID, + UserID: req.UserId, + Roles: req.Roles, } } -func UpdateIAMMemberToDomain(req *admin_pb.UpdateIAMMemberRequest) *domain.Member { - return &domain.Member{ - UserID: req.UserId, - Roles: req.Roles, +func UpdateIAMMemberToCommand(req *admin_pb.UpdateIAMMemberRequest, instanceID string) *command.ChangeInstanceMember { + return &command.ChangeInstanceMember{ + InstanceID: instanceID, + UserID: req.UserId, + Roles: req.Roles, } } diff --git a/internal/api/grpc/admin/iam_member_converter_test.go b/internal/api/grpc/admin/iam_member_converter_test.go index 74dd329ee1..70e282d621 100644 --- a/internal/api/grpc/admin/iam_member_converter_test.go +++ b/internal/api/grpc/admin/iam_member_converter_test.go @@ -27,7 +27,7 @@ func TestAddIAMMemberToDomain(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := AddIAMMemberToDomain(tt.args.req) + got := AddIAMMemberToCommand(tt.args.req, "INSTANCE") test.AssertFieldsMapped(t, got, "ObjectRoot") }) } @@ -53,7 +53,7 @@ func TestUpdateIAMMemberToDomain(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := UpdateIAMMemberToDomain(tt.args.req) + got := UpdateIAMMemberToCommand(tt.args.req, "INSTANCE") test.AssertFieldsMapped(t, got, "ObjectRoot") }) } diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 84b0215f03..ac085c135b 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -1046,7 +1046,7 @@ func importOrg2(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDataEr if org.UserGrants != nil { for _, grant := range org.GetUserGrants() { logging.Debugf("import usergrant: %s", grant.GetProjectId()+"_"+grant.GetUserId()) - _, err := s.command.AddUserGrant(ctx, management.AddUserGrantRequestToDomain(grant), org.GetOrgId()) + _, err := s.command.AddUserGrant(ctx, management.AddUserGrantRequestToDomain(grant, org.GetOrgId()), nil) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "user_grant", Id: org.GetOrgId() + "_" + grant.GetProjectId() + "_" + grant.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { @@ -1088,7 +1088,7 @@ func importOrgMembers(ctx context.Context, s *Server, errors *[]*admin_pb.Import } for _, member := range org.GetOrgMembers() { logging.Debugf("import orgmember: %s", member.GetUserId()) - _, err := s.command.AddOrgMember(ctx, org.GetOrgId(), member.GetUserId(), member.GetRoles()...) + _, err := s.command.AddOrgMember(ctx, management.AddOrgMemberRequestToCommand(member, org.GetOrgId())) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "org_member", Id: org.GetOrgId() + "_" + member.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { @@ -1112,7 +1112,7 @@ func importProjectGrantMembers(ctx context.Context, s *Server, errors *[]*admin_ } for _, member := range org.GetProjectGrantMembers() { logging.Debugf("import projectgrantmember: %s", member.GetProjectId()+"_"+member.GetGrantId()+"_"+member.GetUserId()) - _, err := s.command.AddProjectGrantMember(ctx, management.AddProjectGrantMemberRequestToDomain(member)) + _, err := s.command.AddProjectGrantMember(ctx, management.AddProjectGrantMemberRequestToCommand(member, org.GetOrgId())) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "project_grant_member", Id: org.GetOrgId() + "_" + member.GetProjectId() + "_" + member.GetGrantId() + "_" + member.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { @@ -1136,7 +1136,7 @@ func importProjectMembers(ctx context.Context, s *Server, errors *[]*admin_pb.Im } for _, member := range org.GetProjectMembers() { logging.Debugf("import orgmember: %s", member.GetProjectId()+"_"+member.GetUserId()) - _, err := s.command.AddProjectMember(ctx, management.AddProjectMemberRequestToDomain(member), org.GetOrgId()) + _, err := s.command.AddProjectMember(ctx, management.AddProjectMemberRequestToCommand(member, org.GetOrgId())) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "project_member", Id: org.GetOrgId() + "_" + member.GetProjectId() + "_" + member.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { diff --git a/internal/api/grpc/admin/integration_test/import_test.go b/internal/api/grpc/admin/integration_test/import_test.go index 4a546bbcf1..3f0d364aec 100644 --- a/internal/api/grpc/admin/integration_test/import_test.go +++ b/internal/api/grpc/admin/integration_test/import_test.go @@ -457,7 +457,7 @@ func TestServer_ImportData(t *testing.T) { { Type: "project_grant_member", Id: orgIDs[5] + "_" + projectIDs[4] + "_" + grantIDs[5] + "_" + userIDs[2], - Message: "ID=V3-DKcYh Message=Errors.Project.Member.AlreadyExists Parent=(ERROR: duplicate key value violates unique constraint \"unique_constraints_pkey\" (SQLSTATE 23505))", + Message: "ID=PROJECT-37fug Message=Errors.AlreadyExists", }, }, Success: &admin.ImportDataSuccess{ diff --git a/internal/api/grpc/app/v2beta/app.go b/internal/api/grpc/app/v2beta/app.go new file mode 100644 index 0000000000..e751bf503f --- /dev/null +++ b/internal/api/grpc/app/v2beta/app.go @@ -0,0 +1,209 @@ +package app + +import ( + "context" + "strings" + "time" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) CreateApplication(ctx context.Context, req *connect.Request[app.CreateApplicationRequest]) (*connect.Response[app.CreateApplicationResponse], error) { + switch t := req.Msg.GetCreationRequestType().(type) { + case *app.CreateApplicationRequest_ApiRequest: + apiApp, err := s.command.AddAPIApplication(ctx, convert.CreateAPIApplicationRequestToDomain(req.Msg.GetName(), req.Msg.GetProjectId(), req.Msg.GetId(), t.ApiRequest), "") + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.CreateApplicationResponse{ + AppId: apiApp.AppID, + CreationDate: timestamppb.New(apiApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_ApiResponse{ + ApiResponse: &app.CreateAPIApplicationResponse{ + ClientId: apiApp.ClientID, + ClientSecret: apiApp.ClientSecretString, + }, + }, + }), nil + + case *app.CreateApplicationRequest_OidcRequest: + oidcAppRequest, err := convert.CreateOIDCAppRequestToDomain(req.Msg.GetName(), req.Msg.GetProjectId(), req.Msg.GetOidcRequest()) + if err != nil { + return nil, err + } + + oidcApp, err := s.command.AddOIDCApplication(ctx, oidcAppRequest, "") + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.CreateApplicationResponse{ + AppId: oidcApp.AppID, + CreationDate: timestamppb.New(oidcApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_OidcResponse{ + OidcResponse: &app.CreateOIDCApplicationResponse{ + ClientId: oidcApp.ClientID, + ClientSecret: oidcApp.ClientSecretString, + NoneCompliant: oidcApp.Compliance.NoneCompliant, + ComplianceProblems: convert.ComplianceProblemsToLocalizedMessages(oidcApp.Compliance.Problems), + }, + }, + }), nil + + case *app.CreateApplicationRequest_SamlRequest: + samlAppRequest, err := convert.CreateSAMLAppRequestToDomain(req.Msg.GetName(), req.Msg.GetProjectId(), req.Msg.GetSamlRequest()) + if err != nil { + return nil, err + } + + samlApp, err := s.command.AddSAMLApplication(ctx, samlAppRequest, "") + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.CreateApplicationResponse{ + AppId: samlApp.AppID, + CreationDate: timestamppb.New(samlApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_SamlResponse{ + SamlResponse: &app.CreateSAMLApplicationResponse{}, + }, + }), nil + default: + return nil, zerrors.ThrowInvalidArgument(nil, "APP-0iiN46", "unknown app type") + } +} + +func (s *Server) UpdateApplication(ctx context.Context, req *connect.Request[app.UpdateApplicationRequest]) (*connect.Response[app.UpdateApplicationResponse], error) { + var changedTime time.Time + + if name := strings.TrimSpace(req.Msg.GetName()); name != "" { + updatedDetails, err := s.command.UpdateApplicationName( + ctx, + req.Msg.GetProjectId(), + &domain.ChangeApp{ + AppID: req.Msg.GetId(), + AppName: name, + }, + "", + ) + if err != nil { + return nil, err + } + + changedTime = updatedDetails.EventDate + } + + switch t := req.Msg.GetUpdateRequestType().(type) { + case *app.UpdateApplicationRequest_ApiConfigurationRequest: + updatedAPIApp, err := s.command.UpdateAPIApplication(ctx, convert.UpdateAPIApplicationConfigurationRequestToDomain(req.Msg.GetId(), req.Msg.GetProjectId(), t.ApiConfigurationRequest), "") + if err != nil { + return nil, err + } + + changedTime = updatedAPIApp.ChangeDate + + case *app.UpdateApplicationRequest_OidcConfigurationRequest: + oidcApp, err := convert.UpdateOIDCAppConfigRequestToDomain(req.Msg.GetId(), req.Msg.GetProjectId(), t.OidcConfigurationRequest) + if err != nil { + return nil, err + } + + updatedOIDCApp, err := s.command.UpdateOIDCApplication(ctx, oidcApp, "") + if err != nil { + return nil, err + } + + changedTime = updatedOIDCApp.ChangeDate + + case *app.UpdateApplicationRequest_SamlConfigurationRequest: + samlApp, err := convert.UpdateSAMLAppConfigRequestToDomain(req.Msg.GetId(), req.Msg.GetProjectId(), t.SamlConfigurationRequest) + if err != nil { + return nil, err + } + + updatedSAMLApp, err := s.command.UpdateSAMLApplication(ctx, samlApp, "") + if err != nil { + return nil, err + } + + changedTime = updatedSAMLApp.ChangeDate + } + + return connect.NewResponse(&app.UpdateApplicationResponse{ + ChangeDate: timestamppb.New(changedTime), + }), nil +} + +func (s *Server) DeleteApplication(ctx context.Context, req *connect.Request[app.DeleteApplicationRequest]) (*connect.Response[app.DeleteApplicationResponse], error) { + details, err := s.command.RemoveApplication(ctx, req.Msg.GetProjectId(), req.Msg.GetId(), "") + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.DeleteApplicationResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) DeactivateApplication(ctx context.Context, req *connect.Request[app.DeactivateApplicationRequest]) (*connect.Response[app.DeactivateApplicationResponse], error) { + details, err := s.command.DeactivateApplication(ctx, req.Msg.GetProjectId(), req.Msg.GetId(), "") + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.DeactivateApplicationResponse{ + DeactivationDate: timestamppb.New(details.EventDate), + }), nil + +} + +func (s *Server) ReactivateApplication(ctx context.Context, req *connect.Request[app.ReactivateApplicationRequest]) (*connect.Response[app.ReactivateApplicationResponse], error) { + details, err := s.command.ReactivateApplication(ctx, req.Msg.GetProjectId(), req.Msg.GetId(), "") + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.ReactivateApplicationResponse{ + ReactivationDate: timestamppb.New(details.EventDate), + }), nil + +} + +func (s *Server) RegenerateClientSecret(ctx context.Context, req *connect.Request[app.RegenerateClientSecretRequest]) (*connect.Response[app.RegenerateClientSecretResponse], error) { + var secret string + var changeDate time.Time + + switch req.Msg.GetAppType().(type) { + case *app.RegenerateClientSecretRequest_IsApi: + config, err := s.command.ChangeAPIApplicationSecret(ctx, req.Msg.GetProjectId(), req.Msg.GetApplicationId(), "") + if err != nil { + return nil, err + } + secret = config.ClientSecretString + changeDate = config.ChangeDate + + case *app.RegenerateClientSecretRequest_IsOidc: + config, err := s.command.ChangeOIDCApplicationSecret(ctx, req.Msg.GetProjectId(), req.Msg.GetApplicationId(), "") + if err != nil { + return nil, err + } + + secret = config.ClientSecretString + changeDate = config.ChangeDate + + default: + return nil, zerrors.ThrowInvalidArgument(nil, "APP-aLWIzw", "unknown app type") + } + + return connect.NewResponse(&app.RegenerateClientSecretResponse{ + ClientSecret: secret, + CreationDate: timestamppb.New(changeDate), + }), nil +} diff --git a/internal/api/grpc/app/v2beta/app_key.go b/internal/api/grpc/app/v2beta/app_key.go new file mode 100644 index 0000000000..087ff90916 --- /dev/null +++ b/internal/api/grpc/app/v2beta/app_key.go @@ -0,0 +1,48 @@ +package app + +import ( + "context" + "strings" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) CreateApplicationKey(ctx context.Context, req *connect.Request[app.CreateApplicationKeyRequest]) (*connect.Response[app.CreateApplicationKeyResponse], error) { + domainReq := convert.CreateAPIClientKeyRequestToDomain(req.Msg) + + appKey, err := s.command.AddApplicationKey(ctx, domainReq, "") + if err != nil { + return nil, err + } + + keyDetails, err := appKey.Detail() + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.CreateApplicationKeyResponse{ + Id: appKey.KeyID, + CreationDate: timestamppb.New(appKey.ChangeDate), + KeyDetails: keyDetails, + }), nil +} + +func (s *Server) DeleteApplicationKey(ctx context.Context, req *connect.Request[app.DeleteApplicationKeyRequest]) (*connect.Response[app.DeleteApplicationKeyResponse], error) { + deletionDetails, err := s.command.RemoveApplicationKey(ctx, + strings.TrimSpace(req.Msg.GetProjectId()), + strings.TrimSpace(req.Msg.GetApplicationId()), + strings.TrimSpace(req.Msg.GetId()), + strings.TrimSpace(req.Msg.GetOrganizationId()), + ) + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.DeleteApplicationKeyResponse{ + DeletionDate: timestamppb.New(deletionDetails.EventDate), + }), nil +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app.go b/internal/api/grpc/app/v2beta/convert/api_app.go new file mode 100644 index 0000000000..4900d534cb --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/api_app.go @@ -0,0 +1,98 @@ +package convert + +import ( + "strings" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateAPIApplicationRequestToDomain(name, projectID, appID string, app *app.CreateAPIApplicationRequest) *domain.APIApp { + return &domain.APIApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + AppID: appID, + AuthMethodType: apiAuthMethodTypeToDomain(app.GetAuthMethodType()), + } +} + +func UpdateAPIApplicationConfigurationRequestToDomain(appID, projectID string, app *app.UpdateAPIApplicationConfigurationRequest) *domain.APIApp { + return &domain.APIApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + AuthMethodType: apiAuthMethodTypeToDomain(app.GetAuthMethodType()), + } +} + +func appAPIConfigToPb(apiApp *query.APIApp) app.ApplicationConfig { + return &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{ + ClientId: apiApp.ClientID, + AuthMethodType: apiAuthMethodTypeToPb(apiApp.AuthMethodType), + }, + } +} + +func apiAuthMethodTypeToDomain(authType app.APIAuthMethodType) domain.APIAuthMethodType { + switch authType { + case app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC: + return domain.APIAuthMethodTypeBasic + case app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT: + return domain.APIAuthMethodTypePrivateKeyJWT + default: + return domain.APIAuthMethodTypeBasic + } +} + +func apiAuthMethodTypeToPb(methodType domain.APIAuthMethodType) app.APIAuthMethodType { + switch methodType { + case domain.APIAuthMethodTypeBasic: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC + case domain.APIAuthMethodTypePrivateKeyJWT: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + default: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC + } +} + +func GetApplicationKeyQueriesRequestToDomain(orgID, projectID, appID string) ([]query.SearchQuery, error) { + var searchQueries []query.SearchQuery + + orgID, projectID, appID = strings.TrimSpace(orgID), strings.TrimSpace(projectID), strings.TrimSpace(appID) + + if orgID != "" { + resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(orgID) + if err != nil { + return nil, err + } + + searchQueries = append(searchQueries, resourceOwner) + } + + if projectID != "" { + aggregateID, err := query.NewAuthNKeyAggregateIDQuery(projectID) + if err != nil { + return nil, err + } + + searchQueries = append(searchQueries, aggregateID) + } + + if appID != "" { + objectID, err := query.NewAuthNKeyObjectIDQuery(appID) + + if err != nil { + return nil, err + } + + searchQueries = append(searchQueries, objectID) + } + + return searchQueries, nil +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app_test.go b/internal/api/grpc/app/v2beta/convert/api_app_test.go new file mode 100644 index 0000000000..dcb87d712f --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/api_app_test.go @@ -0,0 +1,218 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateAPIApplicationRequestToDomain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + appName string + projectID string + appID string + req *app.CreateAPIApplicationRequest + want *domain.APIApp + }{ + { + name: "basic auth method", + appName: "my-app", + projectID: "proj-1", + appID: "someID", + req: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppName: "my-app", + AuthMethodType: domain.APIAuthMethodTypeBasic, + AppID: "someID", + }, + }, + { + name: "private key jwt", + appName: "jwt-app", + projectID: "proj-2", + req: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-2"}, + AppName: "jwt-app", + AuthMethodType: domain.APIAuthMethodTypePrivateKeyJWT, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + got := CreateAPIApplicationRequestToDomain(tt.appName, tt.projectID, tt.appID, tt.req) + + // Then + assert.Equal(t, tt.want, got) + }) + } +} + +func TestUpdateAPIApplicationConfigurationRequestToDomain(t *testing.T) { + t.Parallel() + tests := []struct { + name string + appID string + projectID string + req *app.UpdateAPIApplicationConfigurationRequest + want *domain.APIApp + }{ + { + name: "basic auth method", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + AuthMethodType: domain.APIAuthMethodTypeBasic, + }, + }, + { + name: "private key jwt", + appID: "app-2", + projectID: "proj-2", + req: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-2"}, + AppID: "app-2", + AuthMethodType: domain.APIAuthMethodTypePrivateKeyJWT, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + got := UpdateAPIApplicationConfigurationRequestToDomain(tt.appID, tt.projectID, tt.req) + + // Then + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_apiAuthMethodTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + methodType domain.APIAuthMethodType + expectedResult app.APIAuthMethodType + }{ + { + name: "basic auth method", + methodType: domain.APIAuthMethodTypeBasic, + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + { + name: "private key jwt", + methodType: domain.APIAuthMethodTypePrivateKeyJWT, + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + { + name: "unknown auth method defaults to basic", + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + res := apiAuthMethodTypeToPb(tc.methodType) + + // Then + assert.Equal(t, tc.expectedResult, res) + }) + } +} +func TestGetApplicationKeyQueriesRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputOrgID string + inputProjectID string + inputAppID string + + expectedQueriesLength int + }{ + { + testName: "all IDs provided", + inputOrgID: "org-1", + inputProjectID: "proj-1", + inputAppID: "app-1", + expectedQueriesLength: 3, + }, + { + testName: "only org ID", + inputOrgID: "org-1", + inputProjectID: " ", + inputAppID: "", + expectedQueriesLength: 1, + }, + { + testName: "only project ID", + inputOrgID: "", + inputProjectID: "proj-1", + inputAppID: " ", + expectedQueriesLength: 1, + }, + { + testName: "only app ID", + inputOrgID: " ", + inputProjectID: "", + inputAppID: "app-1", + expectedQueriesLength: 1, + }, + { + testName: "empty IDs", + inputOrgID: " ", + inputProjectID: " ", + inputAppID: " ", + expectedQueriesLength: 0, + }, + { + testName: "with spaces", + inputOrgID: " org-1 ", + inputProjectID: " proj-1 ", + inputAppID: " app-1 ", + expectedQueriesLength: 3, + }, + } + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := GetApplicationKeyQueriesRequestToDomain(tc.inputOrgID, tc.inputProjectID, tc.inputAppID) + + // Then + require.NoError(t, err) + + assert.Len(t, got, tc.expectedQueriesLength) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/convert.go b/internal/api/grpc/app/v2beta/convert/convert.go new file mode 100644 index 0000000000..a0a1d5ef05 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/convert.go @@ -0,0 +1,262 @@ +package convert + +import ( + "net/url" + "strings" + + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func AppToPb(query_app *query.App) *app.Application { + if query_app == nil { + return &app.Application{} + } + + return &app.Application{ + Id: query_app.ID, + CreationDate: timestamppb.New(query_app.CreationDate), + ChangeDate: timestamppb.New(query_app.ChangeDate), + State: appStateToPb(query_app.State), + Name: query_app.Name, + Config: appConfigToPb(query_app), + } +} + +func AppsToPb(queryApps []*query.App) []*app.Application { + pbApps := make([]*app.Application, len(queryApps)) + + for i, queryApp := range queryApps { + pbApps[i] = AppToPb(queryApp) + } + + return pbApps +} + +func ListApplicationsRequestToModel(sysDefaults systemdefaults.SystemDefaults, req *app.ListApplicationsRequest) (*query.AppSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := appQueriesToModel(req.GetFilters()) + if err != nil { + return nil, err + } + projectQuery, err := query.NewAppProjectIDSearchQuery(req.GetProjectId()) + if err != nil { + return nil, err + } + + queries = append(queries, projectQuery) + return &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: appSortingToColumn(req.GetSortingColumn()), + }, + + Queries: queries, + }, nil +} + +func appSortingToColumn(sortingCriteria app.AppSorting) query.Column { + switch sortingCriteria { + case app.AppSorting_APP_SORT_BY_CHANGE_DATE: + return query.AppColumnChangeDate + case app.AppSorting_APP_SORT_BY_CREATION_DATE: + return query.AppColumnCreationDate + case app.AppSorting_APP_SORT_BY_NAME: + return query.AppColumnName + case app.AppSorting_APP_SORT_BY_STATE: + return query.AppColumnState + case app.AppSorting_APP_SORT_BY_ID: + fallthrough + default: + return query.AppColumnID + } +} + +func appStateToPb(state domain.AppState) app.AppState { + switch state { + case domain.AppStateActive: + return app.AppState_APP_STATE_ACTIVE + case domain.AppStateInactive: + return app.AppState_APP_STATE_INACTIVE + case domain.AppStateRemoved: + return app.AppState_APP_STATE_REMOVED + case domain.AppStateUnspecified: + fallthrough + default: + return app.AppState_APP_STATE_UNSPECIFIED + } +} + +func appConfigToPb(app *query.App) app.ApplicationConfig { + if app.OIDCConfig != nil { + return appOIDCConfigToPb(app.OIDCConfig) + } + if app.SAMLConfig != nil { + return appSAMLConfigToPb(app.SAMLConfig) + } + return appAPIConfigToPb(app.APIConfig) +} + +func loginVersionToDomain(version *app.LoginVersion) (*domain.LoginVersion, *string, error) { + switch v := version.GetVersion().(type) { + case nil: + return gu.Ptr(domain.LoginVersionUnspecified), gu.Ptr(""), nil + case *app.LoginVersion_LoginV1: + return gu.Ptr(domain.LoginVersion1), gu.Ptr(""), nil + case *app.LoginVersion_LoginV2: + _, err := url.Parse(v.LoginV2.GetBaseUri()) + return gu.Ptr(domain.LoginVersion2), gu.Ptr(v.LoginV2.GetBaseUri()), err + default: + return gu.Ptr(domain.LoginVersionUnspecified), gu.Ptr(""), nil + } +} + +func loginVersionToPb(version domain.LoginVersion, baseURI *string) *app.LoginVersion { + switch version { + case domain.LoginVersionUnspecified: + return nil + case domain.LoginVersion1: + return &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}} + case domain.LoginVersion2: + return &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: baseURI}}} + default: + return nil + } +} + +func appQueriesToModel(queries []*app.ApplicationSearchFilter) (toReturn []query.SearchQuery, err error) { + toReturn = make([]query.SearchQuery, len(queries)) + for i, query := range queries { + toReturn[i], err = appQueryToModel(query) + if err != nil { + return nil, err + } + } + return toReturn, nil +} + +func appQueryToModel(appQuery *app.ApplicationSearchFilter) (query.SearchQuery, error) { + switch q := appQuery.GetFilter().(type) { + case *app.ApplicationSearchFilter_NameFilter: + return query.NewAppNameSearchQuery(filter.TextMethodPbToQuery(q.NameFilter.GetMethod()), q.NameFilter.Name) + case *app.ApplicationSearchFilter_StateFilter: + return query.NewAppStateSearchQuery(domain.AppState(q.StateFilter)) + case *app.ApplicationSearchFilter_ApiAppOnly: + return query.NewNotNullQuery(query.AppAPIConfigColumnAppID) + case *app.ApplicationSearchFilter_OidcAppOnly: + return query.NewNotNullQuery(query.AppOIDCConfigColumnAppID) + case *app.ApplicationSearchFilter_SamlAppOnly: + return query.NewNotNullQuery(query.AppSAMLConfigColumnAppID) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid") + } +} + +func CreateAPIClientKeyRequestToDomain(key *app.CreateApplicationKeyRequest) *domain.ApplicationKey { + return &domain.ApplicationKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: strings.TrimSpace(key.GetProjectId()), + }, + ExpirationDate: key.GetExpirationDate().AsTime(), + Type: domain.AuthNKeyTypeJSON, + ApplicationID: strings.TrimSpace(key.GetAppId()), + } +} + +func ListApplicationKeysRequestToDomain(sysDefaults systemdefaults.SystemDefaults, req *app.ListApplicationKeysRequest) (*query.AuthNKeySearchQueries, error) { + var queries []query.SearchQuery + + switch req.GetResourceId().(type) { + case *app.ListApplicationKeysRequest_ApplicationId: + object, err := query.NewAuthNKeyObjectIDQuery(strings.TrimSpace(req.GetApplicationId())) + if err != nil { + return nil, err + } + queries = append(queries, object) + case *app.ListApplicationKeysRequest_OrganizationId: + resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(strings.TrimSpace(req.GetOrganizationId())) + if err != nil { + return nil, err + } + queries = append(queries, resourceOwner) + case *app.ListApplicationKeysRequest_ProjectId: + aggregate, err := query.NewAuthNKeyAggregateIDQuery(strings.TrimSpace(req.GetProjectId())) + if err != nil { + return nil, err + } + queries = append(queries, aggregate) + case nil: + + default: + return nil, zerrors.ThrowInvalidArgument(nil, "CONV-t3ENme", "unexpected resource id") + } + + offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + + return &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: appKeysSortingToColumn(req.GetSortingColumn()), + }, + + Queries: queries, + }, nil +} + +func appKeysSortingToColumn(sortingCriteria app.ApplicationKeysSorting) query.Column { + switch sortingCriteria { + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_PROJECT_ID: + return query.AuthNKeyColumnAggregateID + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE: + return query.AuthNKeyColumnCreationDate + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION: + return query.AuthNKeyColumnExpiration + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_ORGANIZATION_ID: + return query.AuthNKeyColumnResourceOwner + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_TYPE: + return query.AuthNKeyColumnType + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_APPLICATION_ID: + return query.AuthNKeyColumnObjectID + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_ID: + fallthrough + default: + return query.AuthNKeyColumnID + } +} + +func ApplicationKeysToPb(keys []*query.AuthNKey) []*app.ApplicationKey { + pbAppKeys := make([]*app.ApplicationKey, len(keys)) + + for i, k := range keys { + pbKey := &app.ApplicationKey{ + Id: k.ID, + ApplicationId: k.ApplicationID, + ProjectId: k.AggregateID, + CreationDate: timestamppb.New(k.CreationDate), + OrganizationId: k.ResourceOwner, + ExpirationDate: timestamppb.New(k.Expiration), + } + pbAppKeys[i] = pbKey + } + + return pbAppKeys +} diff --git a/internal/api/grpc/app/v2beta/convert/convert_test.go b/internal/api/grpc/app/v2beta/convert/convert_test.go new file mode 100644 index 0000000000..8715d2a5dd --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/convert_test.go @@ -0,0 +1,704 @@ +package convert + +import ( + "errors" + "fmt" + "net/url" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + filter_pb_v2 "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + filter_pb_v2_beta "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" +) + +func TestAppToPb(t *testing.T) { + t.Parallel() + + now := time.Now() + + tt := []struct { + testName string + inputQueryApp *query.App + expectedPbApp *app.Application + }{ + { + testName: "full app conversion", + inputQueryApp: &query.App{ + ID: "id", + CreationDate: now, + ChangeDate: now, + State: domain.AppStateActive, + Name: "test-app", + APIConfig: &query.APIApp{}, + }, + expectedPbApp: &app.Application{ + Id: "id", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + State: app.AppState_APP_STATE_ACTIVE, + Name: "test-app", + Config: &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{}, + }, + }, + }, + { + testName: "nil app", + inputQueryApp: nil, + expectedPbApp: &app.Application{}, + }, + } + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := AppToPb(tc.inputQueryApp) + + // Then + assert.Equal(t, tc.expectedPbApp, res) + }) + } +} + +func TestListApplicationsRequestToModel(t *testing.T) { + t.Parallel() + + validSearchByNameQuery, err := query.NewAppNameSearchQuery(filter.TextMethodPbToQuery(filter_pb_v2_beta.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS), "test") + require.NoError(t, err) + + validSearchByProjectQuery, err := query.NewAppProjectIDSearchQuery("project1") + require.NoError(t, err) + + sysDefaults := systemdefaults.SystemDefaults{DefaultQueryLimit: 100, MaxQueryLimit: 150} + + tt := []struct { + testName string + req *app.ListApplicationsRequest + + expectedResponse *query.AppSearchQueries + expectedError error + }{ + { + testName: "invalid pagination limit", + req: &app.ListApplicationsRequest{ + Pagination: &filter_pb_v2.PaginationRequest{Asc: true, Limit: uint32(sysDefaults.MaxQueryLimit + 1)}, + }, + expectedResponse: nil, + expectedError: zerrors.ThrowInvalidArgumentf(fmt.Errorf("given: %d, allowed: %d", sysDefaults.MaxQueryLimit+1, sysDefaults.MaxQueryLimit), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + testName: "empty request", + req: &app.ListApplicationsRequest{ + ProjectId: "project1", + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResponse: &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AppColumnID, + }, + Queries: []query.SearchQuery{ + validSearchByProjectQuery, + }, + }, + }, + { + testName: "valid request", + req: &app.ListApplicationsRequest{ + ProjectId: "project1", + Filters: []*app.ApplicationSearchFilter{ + { + Filter: &app.ApplicationSearchFilter_NameFilter{NameFilter: &app.ApplicationNameQuery{Name: "test"}}, + }, + }, + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + + expectedResponse: &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AppColumnName, + }, + Queries: []query.SearchQuery{ + validSearchByNameQuery, + validSearchByProjectQuery, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := ListApplicationsRequestToModel(sysDefaults, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, got) + }) + } +} + +func TestAppSortingToColumn(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + sorting app.AppSorting + expected query.Column + }{ + { + name: "sort by change date", + sorting: app.AppSorting_APP_SORT_BY_CHANGE_DATE, + expected: query.AppColumnChangeDate, + }, + { + name: "sort by creation date", + sorting: app.AppSorting_APP_SORT_BY_CREATION_DATE, + expected: query.AppColumnCreationDate, + }, + { + name: "sort by name", + sorting: app.AppSorting_APP_SORT_BY_NAME, + expected: query.AppColumnName, + }, + { + name: "sort by state", + sorting: app.AppSorting_APP_SORT_BY_STATE, + expected: query.AppColumnState, + }, + { + name: "sort by ID", + sorting: app.AppSorting_APP_SORT_BY_ID, + expected: query.AppColumnID, + }, + { + name: "unknown sorting defaults to ID", + sorting: app.AppSorting(99), + expected: query.AppColumnID, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appSortingToColumn(tc.sorting) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppStateToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + state domain.AppState + expected app.AppState + }{ + { + name: "active state", + state: domain.AppStateActive, + expected: app.AppState_APP_STATE_ACTIVE, + }, + { + name: "inactive state", + state: domain.AppStateInactive, + expected: app.AppState_APP_STATE_INACTIVE, + }, + { + name: "removed state", + state: domain.AppStateRemoved, + expected: app.AppState_APP_STATE_REMOVED, + }, + { + name: "unspecified state", + state: domain.AppStateUnspecified, + expected: app.AppState_APP_STATE_UNSPECIFIED, + }, + { + name: "unknown state defaults to unspecified", + state: domain.AppState(99), + expected: app.AppState_APP_STATE_UNSPECIFIED, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appStateToPb(tc.state) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppConfigToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + app *query.App + expected app.ApplicationConfig + }{ + { + name: "OIDC config", + app: &query.App{ + OIDCConfig: &query.OIDCApp{}, + }, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + ResponseTypes: []app.OIDCResponseType{}, + GrantTypes: []app.OIDCGrantType{}, + ComplianceProblems: []*app.OIDCLocalizedMessage{}, + ClockSkew: &durationpb.Duration{}, + }, + }, + }, + { + name: "SAML config", + app: &query.App{ + SAMLConfig: &query.SAMLApp{}, + }, + expected: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + }, + }, + }, + { + name: "API config", + app: &query.App{ + APIConfig: &query.APIApp{}, + }, + expected: &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appConfigToPb(tc.app) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestLoginVersionToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + version *app.LoginVersion + expectedVer *domain.LoginVersion + expectedURI *string + expectedError error + }{ + { + name: "nil version", + version: nil, + expectedVer: gu.Ptr(domain.LoginVersionUnspecified), + expectedURI: gu.Ptr(""), + }, + { + name: "login v1", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + expectedVer: gu.Ptr(domain.LoginVersion1), + expectedURI: gu.Ptr(""), + }, + { + name: "login v2 valid URI", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://valid.url")}}}, + expectedVer: gu.Ptr(domain.LoginVersion2), + expectedURI: gu.Ptr("https://valid.url"), + }, + { + name: "login v2 invalid URI", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: gu.Ptr("://invalid")}}}, + expectedVer: gu.Ptr(domain.LoginVersion2), + expectedURI: gu.Ptr("://invalid"), + expectedError: &url.Error{Op: "parse", URL: "://invalid", Err: errors.New("missing protocol scheme")}, + }, + { + name: "unknown version type", + version: &app.LoginVersion{}, + expectedVer: gu.Ptr(domain.LoginVersionUnspecified), + expectedURI: gu.Ptr(""), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + version, uri, err := loginVersionToDomain(tc.version) + + // Then + assert.Equal(t, tc.expectedVer, version) + assert.Equal(t, tc.expectedURI, uri) + assert.Equal(t, tc.expectedError, err) + }) + } +} + +func TestLoginVersionToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + version domain.LoginVersion + baseURI *string + expected *app.LoginVersion + }{ + { + name: "unspecified version", + version: domain.LoginVersionUnspecified, + baseURI: nil, + expected: nil, + }, + { + name: "login v1", + version: domain.LoginVersion1, + baseURI: nil, + expected: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV1{ + LoginV1: &app.LoginV1{}, + }, + }, + }, + { + name: "login v2", + version: domain.LoginVersion2, + baseURI: gu.Ptr("https://example.com"), + expected: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://example.com"), + }, + }, + }, + }, + { + name: "unknown version", + version: domain.LoginVersion(99), + baseURI: nil, + expected: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := loginVersionToPb(tc.version, tc.baseURI) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppQueryToModel(t *testing.T) { + t.Parallel() + + validAppNameSearchQuery, err := query.NewAppNameSearchQuery(query.TextEquals, "test") + require.NoError(t, err) + + validAppStateSearchQuery, err := query.NewAppStateSearchQuery(domain.AppStateActive) + require.NoError(t, err) + + tt := []struct { + name string + query *app.ApplicationSearchFilter + + expectedQuery query.SearchQuery + expectedError error + }{ + { + name: "name query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_NameFilter{ + NameFilter: &app.ApplicationNameQuery{ + Name: "test", + Method: filter_pb_v2.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }, + }, + expectedQuery: validAppNameSearchQuery, + }, + { + name: "state query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_StateFilter{ + StateFilter: app.AppState_APP_STATE_ACTIVE, + }, + }, + expectedQuery: validAppStateSearchQuery, + }, + { + name: "api app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_ApiAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppAPIConfigColumnAppID, + }, + }, + { + name: "oidc app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_OidcAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppOIDCConfigColumnAppID, + }, + }, + { + name: "saml app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_SamlAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppSAMLConfigColumnAppID, + }, + }, + { + name: "invalid query type", + query: &app.ApplicationSearchFilter{}, + expectedQuery: nil, + expectedError: zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid"), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result, err := appQueryToModel(tc.query) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedQuery, result) + }) + } +} + +func TestListApplicationKeysRequestToDomain(t *testing.T) { + t.Parallel() + + resourceOwnerQuery, err := query.NewAuthNKeyResourceOwnerQuery("org1") + require.NoError(t, err) + + projectIDQuery, err := query.NewAuthNKeyAggregateIDQuery("project1") + require.NoError(t, err) + + appIDQuery, err := query.NewAuthNKeyObjectIDQuery("app1") + require.NoError(t, err) + + sysDefaults := systemdefaults.SystemDefaults{DefaultQueryLimit: 100, MaxQueryLimit: 150} + + tt := []struct { + name string + req *app.ListApplicationKeysRequest + + expectedResult *query.AuthNKeySearchQueries + expectedError error + }{ + { + name: "invalid pagination limit", + req: &app.ListApplicationKeysRequest{ + Pagination: &filter_pb_v2.PaginationRequest{Asc: true, Limit: uint32(sysDefaults.MaxQueryLimit + 1)}, + }, + expectedResult: nil, + expectedError: zerrors.ThrowInvalidArgumentf(fmt.Errorf("given: %d, allowed: %d", sysDefaults.MaxQueryLimit+1, sysDefaults.MaxQueryLimit), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + name: "empty request", + req: &app.ListApplicationKeysRequest{ + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: nil, + }, + }, + { + name: "only organization id", + req: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_OrganizationId{OrganizationId: "org1"}, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: []query.SearchQuery{ + resourceOwnerQuery, + }, + }, + }, + { + name: "only project id", + req: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: "project1"}, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: []query.SearchQuery{ + projectIDQuery, + }, + }, + }, + { + name: "only application id", + req: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: "app1"}, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: []query.SearchQuery{ + appIDQuery, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result, err := ListApplicationKeysRequestToDomain(sysDefaults, tc.req) + + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +func TestApplicationKeysToPb(t *testing.T) { + t.Parallel() + + now := time.Now() + + tt := []struct { + name string + input []*query.AuthNKey + expected []*app.ApplicationKey + }{ + { + name: "multiple keys", + input: []*query.AuthNKey{ + { + ID: "key1", + AggregateID: "project1", + ApplicationID: "app1", + CreationDate: now, + ResourceOwner: "org1", + Expiration: now.Add(24 * time.Hour), + Type: domain.AuthNKeyTypeJSON, + }, + { + ID: "key2", + AggregateID: "project2", + ApplicationID: "app1", + CreationDate: now.Add(-time.Hour), + ResourceOwner: "org2", + Expiration: now.Add(48 * time.Hour), + Type: domain.AuthNKeyTypeNONE, + }, + }, + expected: []*app.ApplicationKey{ + { + Id: "key1", + ApplicationId: "app1", + ProjectId: "project1", + CreationDate: timestamppb.New(now), + OrganizationId: "org1", + ExpirationDate: timestamppb.New(now.Add(24 * time.Hour)), + }, + { + Id: "key2", + ApplicationId: "app1", + ProjectId: "project2", + CreationDate: timestamppb.New(now.Add(-time.Hour)), + OrganizationId: "org2", + ExpirationDate: timestamppb.New(now.Add(48 * time.Hour)), + }, + }, + }, + { + name: "empty slice", + input: []*query.AuthNKey{}, + expected: []*app.ApplicationKey{}, + }, + { + name: "nil input", + input: nil, + expected: []*app.ApplicationKey{}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := ApplicationKeysToPb(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/oidc_app.go b/internal/api/grpc/app/v2beta/convert/oidc_app.go new file mode 100644 index 0000000000..223e43d166 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/oidc_app.go @@ -0,0 +1,291 @@ +package convert + +import ( + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateOIDCAppRequestToDomain(name, projectID string, req *app.CreateOIDCApplicationRequest) (*domain.OIDCApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(req.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: req.GetRedirectUris(), + ResponseTypes: oidcResponseTypesToDomain(req.GetResponseTypes()), + GrantTypes: oidcGrantTypesToDomain(req.GetGrantTypes()), + ApplicationType: gu.Ptr(oidcApplicationTypeToDomain(req.GetAppType())), + AuthMethodType: gu.Ptr(oidcAuthMethodTypeToDomain(req.GetAuthMethodType())), + PostLogoutRedirectUris: req.GetPostLogoutRedirectUris(), + DevMode: &req.DevMode, + AccessTokenType: gu.Ptr(oidcTokenTypeToDomain(req.GetAccessTokenType())), + AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), + AdditionalOrigins: req.GetAdditionalOrigins(), + SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func UpdateOIDCAppConfigRequestToDomain(appID, projectID string, app *app.UpdateOIDCApplicationConfigurationRequest) (*domain.OIDCApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(app.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + RedirectUris: app.RedirectUris, + ResponseTypes: oidcResponseTypesToDomain(app.ResponseTypes), + GrantTypes: oidcGrantTypesToDomain(app.GrantTypes), + ApplicationType: oidcApplicationTypeToDomainPtr(app.AppType), + AuthMethodType: oidcAuthMethodTypeToDomainPtr(app.AuthMethodType), + PostLogoutRedirectUris: app.PostLogoutRedirectUris, + DevMode: app.DevMode, + AccessTokenType: oidcTokenTypeToDomainPtr(app.AccessTokenType), + AccessTokenRoleAssertion: app.AccessTokenRoleAssertion, + IDTokenRoleAssertion: app.IdTokenRoleAssertion, + IDTokenUserinfoAssertion: app.IdTokenUserinfoAssertion, + ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), + AdditionalOrigins: app.AdditionalOrigins, + SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, + BackChannelLogoutURI: app.BackChannelLogoutUri, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func oidcResponseTypesToDomain(responseTypes []app.OIDCResponseType) []domain.OIDCResponseType { + if len(responseTypes) == 0 { + return []domain.OIDCResponseType{domain.OIDCResponseTypeCode} + } + oidcResponseTypes := make([]domain.OIDCResponseType, len(responseTypes)) + for i, responseType := range responseTypes { + switch responseType { + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED: + oidcResponseTypes[i] = domain.OIDCResponseTypeUnspecified + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE: + oidcResponseTypes[i] = domain.OIDCResponseTypeCode + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN: + oidcResponseTypes[i] = domain.OIDCResponseTypeIDToken + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN: + oidcResponseTypes[i] = domain.OIDCResponseTypeIDTokenToken + } + } + return oidcResponseTypes +} + +func oidcGrantTypesToDomain(grantTypes []app.OIDCGrantType) []domain.OIDCGrantType { + if len(grantTypes) == 0 { + return []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode} + } + oidcGrantTypes := make([]domain.OIDCGrantType, len(grantTypes)) + for i, grantType := range grantTypes { + switch grantType { + case app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE: + oidcGrantTypes[i] = domain.OIDCGrantTypeAuthorizationCode + case app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT: + oidcGrantTypes[i] = domain.OIDCGrantTypeImplicit + case app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN: + oidcGrantTypes[i] = domain.OIDCGrantTypeRefreshToken + case app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE: + oidcGrantTypes[i] = domain.OIDCGrantTypeDeviceCode + case app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE: + oidcGrantTypes[i] = domain.OIDCGrantTypeTokenExchange + } + } + return oidcGrantTypes +} + +func oidcApplicationTypeToDomainPtr(appType *app.OIDCAppType) *domain.OIDCApplicationType { + if appType == nil { + return nil + } + + res := oidcApplicationTypeToDomain(*appType) + return &res +} + +func oidcApplicationTypeToDomain(appType app.OIDCAppType) domain.OIDCApplicationType { + switch appType { + case app.OIDCAppType_OIDC_APP_TYPE_WEB: + return domain.OIDCApplicationTypeWeb + case app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT: + return domain.OIDCApplicationTypeUserAgent + case app.OIDCAppType_OIDC_APP_TYPE_NATIVE: + return domain.OIDCApplicationTypeNative + } + return domain.OIDCApplicationTypeWeb +} + +func oidcAuthMethodTypeToDomainPtr(authType *app.OIDCAuthMethodType) *domain.OIDCAuthMethodType { + if authType == nil { + return nil + } + + res := oidcAuthMethodTypeToDomain(*authType) + return &res +} + +func oidcAuthMethodTypeToDomain(authType app.OIDCAuthMethodType) domain.OIDCAuthMethodType { + switch authType { + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC: + return domain.OIDCAuthMethodTypeBasic + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST: + return domain.OIDCAuthMethodTypePost + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE: + return domain.OIDCAuthMethodTypeNone + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT: + return domain.OIDCAuthMethodTypePrivateKeyJWT + default: + return domain.OIDCAuthMethodTypeBasic + } +} + +func oidcTokenTypeToDomainPtr(tokenType *app.OIDCTokenType) *domain.OIDCTokenType { + if tokenType == nil { + return nil + } + + res := oidcTokenTypeToDomain(*tokenType) + return &res +} + +func oidcTokenTypeToDomain(tokenType app.OIDCTokenType) domain.OIDCTokenType { + switch tokenType { + case app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER: + return domain.OIDCTokenTypeBearer + case app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT: + return domain.OIDCTokenTypeJWT + default: + return domain.OIDCTokenTypeBearer + } +} + +func ComplianceProblemsToLocalizedMessages(complianceProblems []string) []*app.OIDCLocalizedMessage { + converted := make([]*app.OIDCLocalizedMessage, len(complianceProblems)) + for i, p := range complianceProblems { + converted[i] = &app.OIDCLocalizedMessage{Key: p} + } + + return converted +} + +func appOIDCConfigToPb(oidcApp *query.OIDCApp) *app.Application_OidcConfig { + return &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + RedirectUris: oidcApp.RedirectURIs, + ResponseTypes: oidcResponseTypesFromModel(oidcApp.ResponseTypes), + GrantTypes: oidcGrantTypesFromModel(oidcApp.GrantTypes), + AppType: oidcApplicationTypeToPb(oidcApp.AppType), + ClientId: oidcApp.ClientID, + AuthMethodType: oidcAuthMethodTypeToPb(oidcApp.AuthMethodType), + PostLogoutRedirectUris: oidcApp.PostLogoutRedirectURIs, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + NoneCompliant: len(oidcApp.ComplianceProblems) != 0, + ComplianceProblems: ComplianceProblemsToLocalizedMessages(oidcApp.ComplianceProblems), + DevMode: oidcApp.IsDevMode, + AccessTokenType: oidcTokenTypeToPb(oidcApp.AccessTokenType), + AccessTokenRoleAssertion: oidcApp.AssertAccessTokenRole, + IdTokenRoleAssertion: oidcApp.AssertIDTokenRole, + IdTokenUserinfoAssertion: oidcApp.AssertIDTokenUserinfo, + ClockSkew: durationpb.New(oidcApp.ClockSkew), + AdditionalOrigins: oidcApp.AdditionalOrigins, + AllowedOrigins: oidcApp.AllowedOrigins, + SkipNativeAppSuccessPage: oidcApp.SkipNativeAppSuccessPage, + BackChannelLogoutUri: oidcApp.BackChannelLogoutURI, + LoginVersion: loginVersionToPb(oidcApp.LoginVersion, oidcApp.LoginBaseURI), + }, + } +} + +func oidcResponseTypesFromModel(responseTypes []domain.OIDCResponseType) []app.OIDCResponseType { + oidcResponseTypes := make([]app.OIDCResponseType, len(responseTypes)) + for i, responseType := range responseTypes { + switch responseType { + case domain.OIDCResponseTypeUnspecified: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED + case domain.OIDCResponseTypeCode: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE + case domain.OIDCResponseTypeIDToken: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN + case domain.OIDCResponseTypeIDTokenToken: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN + } + } + return oidcResponseTypes +} + +func oidcGrantTypesFromModel(grantTypes []domain.OIDCGrantType) []app.OIDCGrantType { + oidcGrantTypes := make([]app.OIDCGrantType, len(grantTypes)) + for i, grantType := range grantTypes { + switch grantType { + case domain.OIDCGrantTypeAuthorizationCode: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE + case domain.OIDCGrantTypeImplicit: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT + case domain.OIDCGrantTypeRefreshToken: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN + case domain.OIDCGrantTypeDeviceCode: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE + case domain.OIDCGrantTypeTokenExchange: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE + } + } + return oidcGrantTypes +} + +func oidcApplicationTypeToPb(appType domain.OIDCApplicationType) app.OIDCAppType { + switch appType { + case domain.OIDCApplicationTypeWeb: + return app.OIDCAppType_OIDC_APP_TYPE_WEB + case domain.OIDCApplicationTypeUserAgent: + return app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT + case domain.OIDCApplicationTypeNative: + return app.OIDCAppType_OIDC_APP_TYPE_NATIVE + default: + return app.OIDCAppType_OIDC_APP_TYPE_WEB + } +} + +func oidcAuthMethodTypeToPb(authType domain.OIDCAuthMethodType) app.OIDCAuthMethodType { + switch authType { + case domain.OIDCAuthMethodTypeBasic: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC + case domain.OIDCAuthMethodTypePost: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST + case domain.OIDCAuthMethodTypeNone: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE + case domain.OIDCAuthMethodTypePrivateKeyJWT: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + default: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC + } +} + +func oidcTokenTypeToPb(tokenType domain.OIDCTokenType) app.OIDCTokenType { + switch tokenType { + case domain.OIDCTokenTypeBearer: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER + case domain.OIDCTokenTypeJWT: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT + default: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER + } +} diff --git a/internal/api/grpc/app/v2beta/convert/oidc_app_test.go b/internal/api/grpc/app/v2beta/convert/oidc_app_test.go new file mode 100644 index 0000000000..a6b3f0b709 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/oidc_app_test.go @@ -0,0 +1,755 @@ +package convert + +import ( + "net/url" + "testing" + "time" + + "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/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateOIDCAppRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + projectID string + req *app.CreateOIDCApplicationRequest + + expectedModel *domain.OIDCApp + expectedError error + }{ + { + testName: "unparsable login version 2 URL", + projectID: "pid", + req: &app.CreateOIDCApplicationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}}, + }, + }, + expectedModel: nil, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "all fields set", + projectID: "project1", + req: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: true, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutUri: "https://backchannel", + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://login"), + }}}, + }, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "project1"}, + AppName: "all fields set", + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login"), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := CreateOIDCAppRequestToDomain(tc.testName, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedModel, res) + }) + } +} + +func TestUpdateOIDCAppConfigRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + + appID string + projectID string + req *app.UpdateOIDCApplicationConfigurationRequest + + expectedModel *domain.OIDCApp + expectedError error + }{ + { + testName: "unparsable login version 2 URL", + appID: "app1", + projectID: "pid", + req: &app.UpdateOIDCApplicationConfigurationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }}, + }, + expectedModel: nil, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "successful Update", + appID: "app1", + projectID: "proj1", + req: &app.UpdateOIDCApplicationConfigurationRequest{ + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: gu.Ptr(app.OIDCAppType_OIDC_APP_TYPE_WEB), + AuthMethodType: gu.Ptr(app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER), + AccessTokenRoleAssertion: gu.Ptr(true), + IdTokenRoleAssertion: gu.Ptr(true), + IdTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutUri: gu.Ptr("https://backchannel"), + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://login")}, + }}, + }, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj1"}, + AppID: "app1", + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login"), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := UpdateOIDCAppConfigRequestToDomain(tc.appID, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedModel, got) + }) + } +} + +func TestOIDCResponseTypesToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputResponseType []app.OIDCResponseType + expectedResponse []domain.OIDCResponseType + }{ + { + testName: "empty response types", + inputResponseType: []app.OIDCResponseType{}, + expectedResponse: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + }, + { + testName: "all response types", + inputResponseType: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN, + }, + expectedResponse: []domain.OIDCResponseType{ + domain.OIDCResponseTypeUnspecified, + domain.OIDCResponseTypeCode, + domain.OIDCResponseTypeIDToken, + domain.OIDCResponseTypeIDTokenToken, + }, + }, + { + testName: "single response type", + inputResponseType: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + }, + expectedResponse: []domain.OIDCResponseType{ + domain.OIDCResponseTypeCode, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := oidcResponseTypesToDomain(tc.inputResponseType) + + // Then + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestOIDCGrantTypesToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputGrantType []app.OIDCGrantType + expectedGrants []domain.OIDCGrantType + }{ + { + testName: "empty grant types", + inputGrantType: []app.OIDCGrantType{}, + expectedGrants: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + }, + { + testName: "all grant types", + inputGrantType: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT, + app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN, + app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + }, + expectedGrants: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + domain.OIDCGrantTypeImplicit, + domain.OIDCGrantTypeRefreshToken, + domain.OIDCGrantTypeDeviceCode, + domain.OIDCGrantTypeTokenExchange, + }, + }, + { + testName: "single grant type", + inputGrantType: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + }, + expectedGrants: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := oidcGrantTypesToDomain(tc.inputGrantType) + + // Then + assert.Equal(t, tc.expectedGrants, res) + }) + } +} + +func TestOIDCApplicationTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + appType app.OIDCAppType + expected domain.OIDCApplicationType + }{ + { + name: "web type", + appType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + expected: domain.OIDCApplicationTypeWeb, + }, + { + name: "user agent type", + appType: app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT, + expected: domain.OIDCApplicationTypeUserAgent, + }, + { + name: "native type", + appType: app.OIDCAppType_OIDC_APP_TYPE_NATIVE, + expected: domain.OIDCApplicationTypeNative, + }, + { + name: "unspecified type defaults to web", + expected: domain.OIDCApplicationTypeWeb, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcApplicationTypeToDomain(tc.appType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCAuthMethodTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + authType app.OIDCAuthMethodType + expectedResponse domain.OIDCAuthMethodType + }{ + { + name: "basic auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + expectedResponse: domain.OIDCAuthMethodTypeBasic, + }, + { + name: "post auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST, + expectedResponse: domain.OIDCAuthMethodTypePost, + }, + { + name: "none auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, + expectedResponse: domain.OIDCAuthMethodTypeNone, + }, + { + name: "private key jwt auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + expectedResponse: domain.OIDCAuthMethodTypePrivateKeyJWT, + }, + { + name: "unspecified auth type defaults to basic", + expectedResponse: domain.OIDCAuthMethodTypeBasic, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + res := oidcAuthMethodTypeToDomain(tc.authType) + + // Then + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestOIDCTokenTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + tokenType app.OIDCTokenType + expectedType domain.OIDCTokenType + }{ + { + name: "bearer token type", + tokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + expectedType: domain.OIDCTokenTypeBearer, + }, + { + name: "jwt token type", + tokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + expectedType: domain.OIDCTokenTypeJWT, + }, + { + name: "unspecified defaults to bearer", + expectedType: domain.OIDCTokenTypeBearer, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcTokenTypeToDomain(tc.tokenType) + + // Then + assert.Equal(t, tc.expectedType, result) + }) + } +} +func TestAppOIDCConfigToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + input *query.OIDCApp + expected *app.Application_OidcConfig + }{ + { + name: "empty config", + input: &query.OIDCApp{}, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + Version: app.OIDCVersion_OIDC_VERSION_1_0, + ComplianceProblems: []*app.OIDCLocalizedMessage{}, + ClockSkew: durationpb.New(0), + ResponseTypes: []app.OIDCResponseType{}, + GrantTypes: []app.OIDCGrantType{}, + }, + }, + }, + { + name: "full config", + input: &query.OIDCApp{ + RedirectURIs: []string{"https://example.com/callback"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + AppType: domain.OIDCApplicationTypeWeb, + ClientID: "client123", + AuthMethodType: domain.OIDCAuthMethodTypeBasic, + PostLogoutRedirectURIs: []string{"https://example.com/logout"}, + ComplianceProblems: []string{"problem1", "problem2"}, + IsDevMode: true, + AccessTokenType: domain.OIDCTokenTypeBearer, + AssertAccessTokenRole: true, + AssertIDTokenRole: true, + AssertIDTokenUserinfo: true, + ClockSkew: 5 * time.Second, + AdditionalOrigins: []string{"https://app.example.com"}, + AllowedOrigins: []string{"https://allowed.example.com"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutURI: "https://example.com/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://login.example.com"), + }, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + RedirectUris: []string{"https://example.com/callback"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + ClientId: "client123", + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"https://example.com/logout"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + NoneCompliant: true, + ComplianceProblems: []*app.OIDCLocalizedMessage{ + {Key: "problem1"}, + {Key: "problem2"}, + }, + DevMode: true, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://app.example.com"}, + AllowedOrigins: []string{"https://allowed.example.com"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutUri: "https://example.com/backchannel", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://login.example.com"), + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tt { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + result := appOIDCConfigToPb(tt.input) + + // Then + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestOIDCResponseTypesFromModel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + responseTypes []domain.OIDCResponseType + expected []app.OIDCResponseType + }{ + { + name: "empty response types", + responseTypes: []domain.OIDCResponseType{}, + expected: []app.OIDCResponseType{}, + }, + { + name: "all response types", + responseTypes: []domain.OIDCResponseType{ + domain.OIDCResponseTypeUnspecified, + domain.OIDCResponseTypeCode, + domain.OIDCResponseTypeIDToken, + domain.OIDCResponseTypeIDTokenToken, + }, + expected: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN, + }, + }, + { + name: "single response type", + responseTypes: []domain.OIDCResponseType{ + domain.OIDCResponseTypeCode, + }, + expected: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcResponseTypesFromModel(tc.responseTypes) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} +func TestOIDCGrantTypesFromModel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + grantTypes []domain.OIDCGrantType + expected []app.OIDCGrantType + }{ + { + name: "empty grant types", + grantTypes: []domain.OIDCGrantType{}, + expected: []app.OIDCGrantType{}, + }, + { + name: "all grant types", + grantTypes: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + domain.OIDCGrantTypeImplicit, + domain.OIDCGrantTypeRefreshToken, + domain.OIDCGrantTypeDeviceCode, + domain.OIDCGrantTypeTokenExchange, + }, + expected: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT, + app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN, + app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + }, + }, + { + name: "single grant type", + grantTypes: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + }, + expected: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcGrantTypesFromModel(tc.grantTypes) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCApplicationTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + appType domain.OIDCApplicationType + expected app.OIDCAppType + }{ + { + name: "web type", + appType: domain.OIDCApplicationTypeWeb, + expected: app.OIDCAppType_OIDC_APP_TYPE_WEB, + }, + { + name: "user agent type", + appType: domain.OIDCApplicationTypeUserAgent, + expected: app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT, + }, + { + name: "native type", + appType: domain.OIDCApplicationTypeNative, + expected: app.OIDCAppType_OIDC_APP_TYPE_NATIVE, + }, + { + name: "unspecified type defaults to web", + appType: domain.OIDCApplicationType(999), // Invalid value to trigger default case + expected: app.OIDCAppType_OIDC_APP_TYPE_WEB, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcApplicationTypeToPb(tc.appType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCAuthMethodTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + authType domain.OIDCAuthMethodType + expected app.OIDCAuthMethodType + }{ + { + name: "basic auth type", + authType: domain.OIDCAuthMethodTypeBasic, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + }, + { + name: "post auth type", + authType: domain.OIDCAuthMethodTypePost, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST, + }, + { + name: "none auth type", + authType: domain.OIDCAuthMethodTypeNone, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, + }, + { + name: "private key jwt auth type", + authType: domain.OIDCAuthMethodTypePrivateKeyJWT, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + { + name: "unknown auth type defaults to basic", + authType: domain.OIDCAuthMethodType(999), + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcAuthMethodTypeToPb(tc.authType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCTokenTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + tokenType domain.OIDCTokenType + expected app.OIDCTokenType + }{ + { + name: "bearer token type", + tokenType: domain.OIDCTokenTypeBearer, + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + }, + { + name: "jwt token type", + tokenType: domain.OIDCTokenTypeJWT, + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + }, + { + name: "unknown token type defaults to bearer", + tokenType: domain.OIDCTokenType(999), // Invalid value to trigger default case + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcTokenTypeToPb(tc.tokenType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/saml_app.go b/internal/api/grpc/app/v2beta/convert/saml_app.go new file mode 100644 index 0000000000..7f1bef082b --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/saml_app.go @@ -0,0 +1,77 @@ +package convert + +import ( + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateSAMLAppRequestToDomain(name, projectID string, req *app.CreateSAMLApplicationRequest) (*domain.SAMLApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(req.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + Metadata: req.GetMetadataXml(), + MetadataURL: gu.Ptr(req.GetMetadataUrl()), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func UpdateSAMLAppConfigRequestToDomain(appID, projectID string, app *app.UpdateSAMLApplicationConfigurationRequest) (*domain.SAMLApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(app.GetLoginVersion()) + if err != nil { + return nil, err + } + + metasXML, metasURL := metasToDomain(app.GetMetadata()) + return &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + Metadata: metasXML, + MetadataURL: metasURL, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func metasToDomain(metas app.MetaType) ([]byte, *string) { + switch t := metas.(type) { + case *app.UpdateSAMLApplicationConfigurationRequest_MetadataXml: + return t.MetadataXml, nil + case *app.UpdateSAMLApplicationConfigurationRequest_MetadataUrl: + return nil, &t.MetadataUrl + case nil: + return nil, nil + default: + return nil, nil + } +} + +func appSAMLConfigToPb(samlApp *query.SAMLApp) app.ApplicationConfig { + if samlApp == nil { + return &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + LoginVersion: &app.LoginVersion{}, + }, + } + } + + return &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{MetadataXml: samlApp.Metadata}, + LoginVersion: loginVersionToPb(samlApp.LoginVersion, samlApp.LoginBaseURI), + }, + } +} diff --git a/internal/api/grpc/app/v2beta/convert/saml_app_test.go b/internal/api/grpc/app/v2beta/convert/saml_app_test.go new file mode 100644 index 0000000000..b41ec432b6 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/saml_app_test.go @@ -0,0 +1,256 @@ +package convert + +import ( + "fmt" + "net/url" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func samlMetadataGen(entityID string) []byte { + str := fmt.Sprintf(` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + +`, + entityID) + + return []byte(str) +} + +func TestCreateSAMLAppRequestToDomain(t *testing.T) { + t.Parallel() + + genMetaForValidRequest := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + testName string + appName string + projectID string + req *app.CreateSAMLApplicationRequest + + expectedResponse *domain.SAMLApp + expectedError error + }{ + { + testName: "login version error", + appName: "test-app", + projectID: "proj-1", + req: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }, + }, + }, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "valid request", + appName: "test-app", + projectID: "proj-1", + req: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: genMetaForValidRequest, + }, + LoginVersion: nil, + }, + + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppName: "test-app", + Metadata: genMetaForValidRequest, + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + State: 0, + }, + }, + { + testName: "nil request", + appName: "test-app", + projectID: "proj-1", + req: nil, + + expectedResponse: &domain.SAMLApp{ + AppName: "test-app", + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := CreateSAMLAppRequestToDomain(tc.appName, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} +func TestUpdateSAMLAppConfigRequestToDomain(t *testing.T) { + t.Parallel() + + genMetaForValidRequest := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + testName string + appID string + projectID string + req *app.UpdateSAMLApplicationConfigurationRequest + + expectedResponse *domain.SAMLApp + expectedError error + }{ + { + testName: "login version error", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }, + }, + }, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "valid request", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: genMetaForValidRequest, + }, + LoginVersion: nil, + }, + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + Metadata: genMetaForValidRequest, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + { + testName: "nil request", + appID: "app-1", + projectID: "proj-1", + req: nil, + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := UpdateSAMLAppConfigRequestToDomain(tc.appID, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestAppSAMLConfigToPb(t *testing.T) { + t.Parallel() + + metadata := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + name string + inputSAMLApp *query.SAMLApp + + expectedPbApp app.ApplicationConfig + }{ + { + name: "valid conversion", + inputSAMLApp: &query.SAMLApp{ + Metadata: metadata, + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://example.com"), + }, + expectedPbApp: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{ + MetadataXml: metadata, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://example.com")}, + }, + }, + }, + }, + }, + { + name: "nil saml app", + inputSAMLApp: nil, + expectedPbApp: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + LoginVersion: &app.LoginVersion{}, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + got := appSAMLConfigToPb(tc.inputSAMLApp) + + // Then + assert.Equal(t, tc.expectedPbApp, got) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/app_key_test.go b/internal/api/grpc/app/v2beta/integration_test/app_key_test.go new file mode 100644 index 0000000000..7c3c886cff --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/app_key_test.go @@ -0,0 +1,206 @@ +//go:build integration + +package app_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateApplicationKey(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + createdApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationKeyRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + { + testName: "when app id is not found should return failed precondition", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: gofakeit.UUID(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateAPIApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + }, + + // LoginUser + { + testName: "when user has no project.app.write permission for app key generation should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // OrgOwner + { + testName: "when user is OrgOwner app key request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + }, + + // ProjectOwner + { + testName: "when user is ProjectOwner app key request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplicationKey(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetId()) + assert.NotZero(t, res.GetCreationDate()) + assert.NotZero(t, res.GetKeyDetails()) + } + }) + } +} + +func TestDeleteApplicationKey(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + createdApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + deletionRequest func(ttt *testing.T) *app.DeleteApplicationKeyRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + { + testName: "when app key ID is not found should return not found error", + inputCtx: IAMOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + return &app.DeleteApplicationKeyRequest{ + Id: gofakeit.UUID(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when valid app key ID should delete successfully", + inputCtx: IAMOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + }, + + // LoginUser + { + testName: "when user has no project.app.write permission for app key deletion should return permission error", + inputCtx: LoginUserCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + expectedErrorType: codes.PermissionDenied, + }, + + // ProjectOwner + { + testName: "when user is OrgOwner API request should succeed", + inputCtx: projectOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + }, + + // OrganizationOwner + { + testName: "when user is OrgOwner app key deletion request should succeed", + inputCtx: OrgOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + deletionReq := tc.deletionRequest(t) + + // When + res, err := instance.Client.AppV2Beta.DeleteApplicationKey(tc.inputCtx, deletionReq) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotEmpty(t, res.GetDeletionDate()) + } + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/app_test.go b/internal/api/grpc/app/v2beta/integration_test/app_test.go new file mode 100644 index 0000000000..67e59aa91d --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/app_test.go @@ -0,0 +1,1446 @@ +//go:build integration + +package app_test + +import ( + "context" + "fmt" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" +) + +func TestCreateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + t.Parallel() + + notExistingProjectID := gofakeit.UUID() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationRequest + inputCtx context.Context + + expectedResponseType string + expectedErrorType codes.Code + }{ + { + testName: "when project for API app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateAPIApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when project for OIDC app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateOIDCApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when project for SAML app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataUrl{ + MetadataUrl: "http://example.com/metas", + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateSAMLApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplication(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + resType := fmt.Sprintf("%T", res.GetCreationResponseType()) + assert.Equal(t, tc.expectedResponseType, resType) + assert.NotZero(t, res.GetAppId()) + assert.NotZero(t, res.GetCreationDate()) + } + }) + } +} + +func TestCreateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + t.Parallel() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationRequest + inputCtx context.Context + + expectedResponseType string + expectedErrorType codes.Code + }{ + // Login User with no project.app.write + { + testName: "when user has no project.app.write permission for API request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for SAML request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + + // OrgOwner with project.app.write permission + { + testName: "when user is OrgOwner API request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when user is OrgOwner OIDC request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when user is OrgOwner SAML request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + + // Project owner with project.app.write permission + { + testName: "when user is ProjectOwner API request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when user is ProjectOwner OIDC request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when user is ProjectOwner SAML request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplication(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + resType := fmt.Sprintf("%T", res.GetCreationResponseType()) + assert.Equal(t, tc.expectedResponseType, resType) + assert.NotZero(t, res.GetAppId()) + assert.NotZero(t, res.GetCreationDate()) + } + }) + } +} + +func TestUpdateApplication(t *testing.T) { + orgNotInCtx := instance.CreateOrganization(IAMOwnerCtx, gofakeit.Name(), gofakeit.Email()) + pNotInCtx := instance.CreateProject(IAMOwnerCtx, t, orgNotInCtx.GetOrganizationId(), gofakeit.AppName(), false, false) + + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + baseURI := "http://example.com" + + t.Cleanup(func() { + instance.Client.OrgV2beta.DeleteOrganization(IAMOwnerCtx, &org.DeleteOrganizationRequest{ + Id: orgNotInCtx.GetOrganizationId(), + }) + }) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + reqForAPIAppCreation := reqForAppNameCreation + + reqForOIDCAppCreation := &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + samlMetas := samlMetadataGen(gofakeit.URL()) + reqForSAMLAppCreation := &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + appForNameChange, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + appForAPIConfigChange, appAPIConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + appForOIDCConfigChange, appOIDCConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForOIDCAppCreation, + }) + require.Nil(t, appOIDCConfigChangeErr) + + appForSAMLConfigChange, appSAMLConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForSAMLAppCreation, + }) + require.Nil(t, appSAMLConfigChangeErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + updateRequest *app.UpdateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app for app name change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForNameChange.GetAppId(), + Name: "New name", + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for app name change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: "New name", + }, + }, + + { + testName: "when app for API config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForAPIConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for API config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when app for OIDC config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForOIDCConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for OIDC config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + + { + testName: "when app for SAML config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForSAMLConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for SAML config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.UpdateApplication(tc.inputCtx, tc.updateRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetChangeDate()) + } + }) + } +} + +func TestUpdateApplication_WithDifferentPermissions(t *testing.T) { + baseURI := "http://example.com" + + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appForNameChange, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + appForAPIConfigChangeForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appForAPIConfigChangeForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appForAPIConfigChangeForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + appForOIDCConfigChangeForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) + appForOIDCConfigChangeForOrgOwner := createOIDCApp(t, baseURI, p.GetId()) + appForOIDCConfigChangeForLoginUser := createOIDCApp(t, baseURI, p.GetId()) + + samlMetasForProjectOwner, appForSAMLConfigChangeForProjectOwner := createSAMLApp(t, baseURI, p.GetId()) + samlMetasForOrgOwner, appForSAMLConfigChangeForOrgOwner := createSAMLApp(t, baseURI, p.GetId()) + samlMetasForLoginUser, appForSAMLConfigChangeForLoginUser := createSAMLApp(t, baseURI, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + updateRequest *app.UpdateApplicationRequest + + expectedErrorType codes.Code + }{ + // ProjectOwner + { + testName: "when user is ProjectOwner app name request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + }, + { + testName: "when user is ProjectOwner API app request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when user is ProjectOwner OIDC app request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + { + testName: "when user is ProjectOwner SAML request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForProjectOwner, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + + // OrgOwner context + { + testName: "when user is OrgOwner app name request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + }, + { + testName: "when user is OrgOwner API app request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when user is OrgOwner OIDC app request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + { + testName: "when user is OrgOwner SAML request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForOrgOwner, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + + // LoginUser + { + testName: "when user has no project.app.write permission for app name change request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for API request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for SAML request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForLoginUser, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.UpdateApplication(tc.inputCtx, tc.updateRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetChangeDate()) + } + }) + } +} + +func TestDeleteApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToDelete, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + t.Parallel() + tt := []struct { + testName string + deleteRequest *app.DeleteApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + { + testName: "when app to delete is not found should return not found error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to delete is found should return deletion time", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDelete.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeleteApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeletionDate()) + } + }) + } +} + +func TestDeleteApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToDeleteForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeleteForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeleteForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + t.Parallel() + tt := []struct { + testName string + deleteRequest *app.DeleteApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.delete permission for app delete request should return permission error", + inputCtx: LoginUserCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForLoginUser.GetAppId(), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner delete app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForProjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner delete app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeleteApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeletionDate()) + } + }) + } +} + +func TestDeactivateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToDeactivate, appCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.NoError(t, appCreateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + deleteRequest *app.DeactivateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app to deactivate is not found should return not found error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to deactivate is found should return deactivation time", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivate.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeactivateApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeactivationDate()) + } + }) + } +} + +func TestDeactivateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToDeactivateForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeactivateForPrjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeactivateForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + deleteRequest *app.DeactivateApplicationRequest + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.write permission for app deactivate request should return permission error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForLoginUser.GetAppId(), + }, + }, + + // Project Owner + { + testName: "when user is ProjectOwner deactivate app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForPrjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner deactivate app request should succeed", + inputCtx: OrgOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeactivateApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeactivationDate()) + } + }) + } +} + +func TestReactivateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToReactivate, appCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appCreateErr) + + _, appDeactivateErr := instance.Client.AppV2Beta.DeactivateApplication(IAMOwnerCtx, &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivate.GetAppId(), + }) + require.Nil(t, appDeactivateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + reactivateRequest *app.ReactivateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app to reactivate is not found should return not found error", + inputCtx: IAMOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to reactivate is found should return deactivation time", + inputCtx: IAMOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivate.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.ReactivateApplication(tc.inputCtx, tc.reactivateRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetReactivationDate()) + } + }) + } +} + +func TestReactivateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToReactivateForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + deactivateApp(t, appToReactivateForLoginUser, p.GetId()) + + appToReactivateForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + deactivateApp(t, appToReactivateForProjectOwner, p.GetId()) + + appToReactivateForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + deactivateApp(t, appToReactivateForOrgOwner, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + reactivateRequest *app.ReactivateApplicationRequest + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.write permission for app reactivate request should return permission error", + inputCtx: LoginUserCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForLoginUser.GetAppId(), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner reactivate app request should succeed", + inputCtx: projectOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForProjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner reactivate app request should succeed", + inputCtx: OrgOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.ReactivateApplication(tc.inputCtx, tc.reactivateRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetReactivationDate()) + } + }) + } +} + +func TestRegenerateClientSecret(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForApiAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + apiAppToRegen, apiAppCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForApiAppCreation, + }) + require.Nil(t, apiAppCreateErr) + + reqForOIDCAppCreation := &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + oidcAppToRegen, oidcAppCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForOIDCAppCreation, + }) + require.Nil(t, oidcAppCreateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + regenRequest *app.RegenerateClientSecretRequest + + expectedErrorType codes.Code + oldSecret string + }{ + { + testName: "when app to regen is not expected type should return invalid argument error", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: gofakeit.Sentence(2), + }, + expectedErrorType: codes.InvalidArgument, + }, + { + testName: "when app to regen is not found should return not found error", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: gofakeit.Sentence(2), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when API app to regen is found should return different secret", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegen.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegen.GetApiResponse().GetClientSecret(), + }, + { + testName: "when OIDC app to regen is found should return different secret", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegen.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegen.GetOidcResponse().GetClientSecret(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.RegenerateClientSecret(tc.inputCtx, tc.regenRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetCreationDate()) + assert.NotEqual(t, tc.oldSecret, res.GetClientSecret()) + } + }) + } + +} + +func TestRegenerateClientSecret_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + apiAppToRegenForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + apiAppToRegenForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + apiAppToRegenForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + oidcAppToRegenForLoginUser := createOIDCApp(t, baseURI, p.GetId()) + oidcAppToRegenForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) + oidcAppToRegenForOrgOwner := createOIDCApp(t, baseURI, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + regenRequest *app.RegenerateClientSecretRequest + + expectedErrorType codes.Code + oldSecret string + }{ + // Login user + { + testName: "when user has no project.app.write permission for API app secret regen request should return permission error", + inputCtx: LoginUserCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForLoginUser.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC app secret regen request should return permission error", + inputCtx: LoginUserCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForLoginUser.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner regen API app secret request should succeed", + inputCtx: projectOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForProjectOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegenForProjectOwner.GetApiResponse().GetClientSecret(), + }, + { + testName: "when user is ProjectOwner regen OIDC app secret request should succeed", + inputCtx: projectOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForProjectOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegenForProjectOwner.GetOidcResponse().GetClientSecret(), + }, + + // Org Owner + { + testName: "when user is OrgOwner regen API app secret request should succeed", + inputCtx: OrgOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForOrgOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegenForOrgOwner.GetApiResponse().GetClientSecret(), + }, + { + testName: "when user is OrgOwner regen OIDC app secret request should succeed", + inputCtx: OrgOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForOrgOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegenForOrgOwner.GetOidcResponse().GetClientSecret(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.RegenerateClientSecret(tc.inputCtx, tc.regenRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetCreationDate()) + assert.NotEqual(t, tc.oldSecret, res.GetClientSecret()) + } + }) + } + +} diff --git a/internal/api/grpc/app/v2beta/integration_test/query_test.go b/internal/api/grpc/app/v2beta/integration_test/query_test.go new file mode 100644 index 0000000000..4f6679da7f --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/query_test.go @@ -0,0 +1,820 @@ +//go:build integration + +package app_test + +import ( + "context" + "fmt" + "slices" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" +) + +func TestGetApplication(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + apiAppName := gofakeit.AppName() + createdApiApp, errAPIAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: apiAppName, + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }) + require.Nil(t, errAPIAppCreation) + + samlAppName := gofakeit.AppName() + createdSAMLApp, errSAMLAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: samlAppName, + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{MetadataXml: samlMetadataGen(gofakeit.URL())}, + }, + }, + }) + require.Nil(t, errSAMLAppCreation) + + oidcAppName := gofakeit.AppName() + createdOIDCApp, errOIDCAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: oidcAppName, + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: &baseURI}}}, + }, + }, + }) + require.Nil(t, errOIDCAppCreation) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.GetApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppName string + expectedAppID string + expectedApplicationType string + }{ + { + testName: "when unknown app ID should return not found error", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: gofakeit.Sentence(2), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when user has no permission should return membership not found error", + inputCtx: NoPermissionCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdApiApp.GetAppId(), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when providing API app ID should return valid API app result", + inputCtx: projectOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdApiApp.GetAppId(), + }, + + expectedAppName: apiAppName, + expectedAppID: createdApiApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_ApiConfig{}), + }, + { + testName: "when providing SAML app ID should return valid SAML app result", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdSAMLApp.GetAppId(), + }, + + expectedAppName: samlAppName, + expectedAppID: createdSAMLApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_SamlConfig{}), + }, + { + testName: "when providing OIDC app ID should return valid OIDC app result", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdOIDCApp.GetAppId(), + }, + + expectedAppName: oidcAppName, + expectedAppID: createdOIDCApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_OidcConfig{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.GetApplication(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + + assert.Equal(t, tc.expectedAppID, res.GetApp().GetId()) + assert.Equal(t, tc.expectedAppName, res.GetApp().GetName()) + assert.NotZero(t, res.GetApp().GetCreationDate()) + assert.NotZero(t, res.GetApp().GetChangeDate()) + + appType := fmt.Sprintf("%T", res.GetApp().GetConfig()) + assert.Equal(t, tc.expectedApplicationType, appType) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplications(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + t.Parallel() + + createdApiApp, apiAppName := createAPIAppWithName(t, IAMOwnerCtx, instance, p.GetId()) + + createdDeactivatedApiApp, deactivatedApiAppName := createAPIAppWithName(t, IAMOwnerCtx, instance, p.GetId()) + deactivateApp(t, createdDeactivatedApiApp, p.GetId()) + + _, createdSAMLApp, samlAppName := createSAMLAppWithName(t, gofakeit.URL(), p.GetId()) + + createdOIDCApp, oidcAppName := createOIDCAppWithName(t, gofakeit.URL(), p.GetId()) + + type appWithName struct { + app *app.CreateApplicationResponse + name string + } + + // Sorting + appsSortedByName := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByName, func(a, b appWithName) int { + if a.name < b.name { + return -1 + } + if a.name > b.name { + return 1 + } + + return 0 + }) + + appsSortedByID := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByID, func(a, b appWithName) int { + if a.app.GetAppId() < b.app.GetAppId() { + return -1 + } + if a.app.GetAppId() > b.app.GetAppId() { + return 1 + } + return 0 + }) + + appsSortedByCreationDate := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByCreationDate, func(a, b appWithName) int { + aCreationDate := a.app.GetCreationDate().AsTime() + bCreationDate := b.app.GetCreationDate().AsTime() + + if aCreationDate.Before(bCreationDate) { + return -1 + } + if bCreationDate.Before(aCreationDate) { + return 1 + } + + return 0 + }) + + tt := []struct { + testName string + inputRequest *app.ListApplicationsRequest + inputCtx context.Context + + expectedOrderedList []appWithName + expectedOrderedKeys func(keys []appWithName) any + actualOrderedKeys func(keys []*app.Application) any + }{ + { + testName: "when no apps found should return empty list", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: "another-id", + }, + + expectedOrderedList: []appWithName{}, + expectedOrderedKeys: func(keys []appWithName) any { return keys }, + actualOrderedKeys: func(keys []*app.Application) any { return keys }, + }, + { + testName: "when user has no read permission should return empty set", + inputCtx: NoPermissionCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedOrderedList: []appWithName{}, + expectedOrderedKeys: func(keys []appWithName) any { return keys }, + actualOrderedKeys: func(keys []*app.Application) any { return keys }, + }, + { + testName: "when sorting by name should return apps sorted by name in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + + expectedOrderedList: appsSortedByName, + expectedOrderedKeys: func(apps []appWithName) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.name + } + + return names + }, + actualOrderedKeys: func(apps []*app.Application) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.GetName() + } + + return names + }, + }, + + { + testName: "when user is project owner should return apps sorted by name in ascending order", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + + expectedOrderedList: appsSortedByName, + expectedOrderedKeys: func(apps []appWithName) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.name + } + + return names + }, + actualOrderedKeys: func(apps []*app.Application) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.GetName() + } + + return names + }, + }, + + { + testName: "when sorting by id should return apps sorted by id in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_ID, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedOrderedList: appsSortedByID, + expectedOrderedKeys: func(apps []appWithName) any { + ids := make([]string, len(apps)) + for i, a := range apps { + ids[i] = a.app.GetAppId() + } + + return ids + }, + actualOrderedKeys: func(apps []*app.Application) any { + ids := make([]string, len(apps)) + for i, a := range apps { + ids[i] = a.GetId() + } + + return ids + }, + }, + { + testName: "when sorting by creation date should return apps sorted by creation date in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_CREATION_DATE, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedOrderedList: appsSortedByCreationDate, + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + { + testName: "when filtering by active apps should return active apps only", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + Pagination: &filter.PaginationRequest{Asc: true}, + Filters: []*app.ApplicationSearchFilter{ + {Filter: &app.ApplicationSearchFilter_StateFilter{StateFilter: app.AppState_APP_STATE_ACTIVE}}, + }, + }, + expectedOrderedList: slices.DeleteFunc( + slices.Clone(appsSortedByID), + func(a appWithName) bool { return a.name == deactivatedApiAppName }, + ), + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + { + testName: "when filtering by app type should return apps of matching type only", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + Pagination: &filter.PaginationRequest{Asc: true}, + Filters: []*app.ApplicationSearchFilter{ + {Filter: &app.ApplicationSearchFilter_OidcAppOnly{}}, + }, + }, + expectedOrderedList: slices.DeleteFunc( + slices.Clone(appsSortedByID), + func(a appWithName) bool { return a.name != oidcAppName }, + ), + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.ListApplications(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, codes.OK, status.Code(err)) + + if err == nil { + assert.Len(ttt, res.GetApplications(), len(tc.expectedOrderedList)) + actualOrderedKeys := tc.actualOrderedKeys(res.GetApplications()) + expectedOrderedKeys := tc.expectedOrderedKeys(tc.expectedOrderedList) + assert.ElementsMatch(ttt, expectedOrderedKeys, actualOrderedKeys) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplications_WithPermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + p, projectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + _, otherProjectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + + appName1, appName2, appName3 := gofakeit.AppName(), gofakeit.AppName(), gofakeit.AppName() + reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + app1, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName1, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + app2, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName2, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + app3, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName3, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.ListApplicationsRequest + inputCtx context.Context + + expectedCode codes.Code + expectedAppIDs []string + }{ + { + testName: "when user has no read permission should return empty set", + inputCtx: instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeNoPermission), + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{}, + }, + { + testName: "when projectOwner should return full app list", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedCode: codes.OK, + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when orgOwner should return full app list", + inputCtx: instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when iamOwner user should return full app list", + inputCtx: iamOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when other projectOwner user should return empty list", + inputCtx: otherProjectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instancePermissionV2.Client.AppV2Beta.ListApplications(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, tc.expectedCode, status.Code(err)) + + if err == nil { + require.Len(ttt, res.GetApplications(), len(tc.expectedAppIDs)) + + resAppIDs := []string{} + for _, a := range res.GetApplications() { + resAppIDs = append(resAppIDs, a.GetId()) + } + + assert.ElementsMatch(ttt, tc.expectedAppIDs, resAppIDs) + } + }, retryDuration, tick) + }) + } +} + +func TestGetApplicationKey(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + createdApiApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + createdAppKey := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.GetApplicationKeyRequest + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppKeyID string + }{ + { + testName: "when unknown app ID should return not found error", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: gofakeit.Sentence(2), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when user has no permission should return membership not found error", + inputCtx: NoPermissionCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when providing API app ID should return valid API app result", + inputCtx: projectOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + }, + + expectedAppKeyID: createdAppKey.GetId(), + }, + { + testName: "when user is OrgOwner should return request key", + inputCtx: OrgOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + }, + + expectedAppKeyID: createdAppKey.GetId(), + }, + { + testName: "when user is IAMOwner should return request key", + inputCtx: OrgOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + OrganizationId: instance.DefaultOrg.GetId(), + }, + + expectedAppKeyID: createdAppKey.GetId(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.GetApplicationKey(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + + assert.Equal(t, tc.expectedAppKeyID, res.GetId()) + assert.NotEmpty(t, res.GetCreationDate()) + assert.NotEmpty(t, res.GetExpirationDate()) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplicationKeys(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + createdApiApp1 := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + createdApiApp2 := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + tomorrow := time.Now().AddDate(0, 0, 1) + in2Days := tomorrow.AddDate(0, 0, 1) + in3Days := in2Days.AddDate(0, 0, 1) + + appKey1 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), in2Days) + appKey2 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), in3Days) + appKey3 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), tomorrow) + appKey4 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp2.GetAppId(), tomorrow) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.ListApplicationKeysRequest + deps func() (projectID, applicationID, organizationID string) + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppKeysIDs []string + }{ + { + testName: "when sorting by expiration date should return keys sorted by expiration date ascending", + inputCtx: LoginUserCtx, + inputRequest: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: p.GetId()}, + Pagination: &filter.PaginationRequest{Asc: true}, + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION, + }, + expectedAppKeysIDs: []string{appKey3.GetId(), appKey4.GetId(), appKey1.GetId(), appKey2.GetId()}, + }, + { + testName: "when sorting by creation date should return keys sorted by creation date descending", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: p.GetId()}, + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE, + }, + expectedAppKeysIDs: []string{appKey4.GetId(), appKey3.GetId(), appKey2.GetId(), appKey1.GetId()}, + }, + { + testName: "when filtering by app ID should return keys matching app ID sorted by ID", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + Pagination: &filter.PaginationRequest{Asc: true}, + ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: createdApiApp1.GetAppId()}, + }, + expectedAppKeysIDs: []string{appKey1.GetId(), appKey2.GetId(), appKey3.GetId()}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.ListApplicationKeys(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + require.Len(ttt, res.GetKeys(), len(tc.expectedAppKeysIDs)) + + for i, k := range res.GetKeys() { + assert.Equal(ttt, tc.expectedAppKeysIDs[i], k.GetId()) + } + } + }, retryDuration, tick) + }) + } +} + +func TestListApplicationKeys_WithPermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + loginUserCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeLogin) + p, projectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + + createdApiApp1 := createAPIApp(t, iamOwnerCtx, instancePermissionV2, p.GetId()) + createdApiApp2 := createAPIApp(t, iamOwnerCtx, instancePermissionV2, p.GetId()) + + tomorrow := time.Now().AddDate(0, 0, 1) + in2Days := tomorrow.AddDate(0, 0, 1) + in3Days := in2Days.AddDate(0, 0, 1) + + appKey1 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), in2Days) + appKey2 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), in3Days) + appKey3 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), tomorrow) + appKey4 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp2.GetAppId(), tomorrow) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.ListApplicationKeysRequest + deps func() (projectID, applicationID, organizationID string) + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppKeysIDs []string + }{ + { + testName: "when sorting by expiration date should return keys sorted by expiration date ascending", + inputCtx: loginUserCtx, + inputRequest: &app.ListApplicationKeysRequest{ + Pagination: &filter.PaginationRequest{Asc: true}, + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION, + }, + expectedAppKeysIDs: []string{appKey3.GetId(), appKey4.GetId(), appKey1.GetId(), appKey2.GetId()}, + }, + { + testName: "when sorting by creation date should return keys sorted by creation date descending", + inputCtx: iamOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE, + }, + expectedAppKeysIDs: []string{appKey4.GetId(), appKey3.GetId(), appKey2.GetId(), appKey1.GetId()}, + }, + { + testName: "when filtering by app ID should return keys matching app ID sorted by ID", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + Pagination: &filter.PaginationRequest{Asc: true}, + ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: createdApiApp1.GetAppId()}, + }, + expectedAppKeysIDs: []string{appKey1.GetId(), appKey2.GetId(), appKey3.GetId()}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instancePermissionV2.Client.AppV2Beta.ListApplicationKeys(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + require.Len(ttt, res.GetKeys(), len(tc.expectedAppKeysIDs)) + + for i, k := range res.GetKeys() { + assert.Equal(ttt, tc.expectedAppKeysIDs[i], k.GetId()) + } + } + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/server_test.go b/internal/api/grpc/app/v2beta/integration_test/server_test.go new file mode 100644 index 0000000000..8ba012c18b --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/server_test.go @@ -0,0 +1,220 @@ +//go:build integration + +package app_test + +import ( + "context" + "fmt" + "os" + "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/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + project_v2beta "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +var ( + NoPermissionCtx context.Context + LoginUserCtx context.Context + OrgOwnerCtx context.Context + IAMOwnerCtx context.Context + + instance *integration.Instance + instancePermissionV2 *integration.Instance + + baseURI = "http://example.com" +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + instance = integration.NewInstance(ctx) + instancePermissionV2 = integration.NewInstance(ctx) + + IAMOwnerCtx = instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + + LoginUserCtx = instance.WithAuthorization(ctx, integration.UserTypeLogin) + OrgOwnerCtx = instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + NoPermissionCtx = instance.WithAuthorization(ctx, integration.UserTypeNoPermission) + + return m.Run() + }()) +} + +func getProjectAndProjectContext(t *testing.T, inst *integration.Instance, ctx context.Context) (*project_v2beta.CreateProjectResponse, context.Context) { + project := inst.CreateProject(ctx, t, inst.DefaultOrg.GetId(), gofakeit.Name(), false, false) + userResp := inst.CreateMachineUser(ctx) + patResp := inst.CreatePersonalAccessToken(ctx, userResp.GetUserId()) + inst.CreateProjectMembership(t, ctx, project.GetId(), userResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(context.Background(), patResp.Token) + + return project, projectOwnerCtx +} + +func samlMetadataGen(entityID string) []byte { + str := fmt.Sprintf(` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + +`, + entityID) + + return []byte(str) +} + +func createSAMLAppWithName(t *testing.T, baseURI, projectID string) ([]byte, *app.CreateApplicationResponse, string) { + samlMetas := samlMetadataGen(gofakeit.URL()) + appName := gofakeit.AppName() + + appForSAMLConfigChange, appSAMLConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }) + require.Nil(t, appSAMLConfigChangeErr) + + return samlMetas, appForSAMLConfigChange, appName +} + +func createSAMLApp(t *testing.T, baseURI, projectID string) ([]byte, *app.CreateApplicationResponse) { + metas, app, _ := createSAMLAppWithName(t, baseURI, projectID) + return metas, app +} + +func createOIDCAppWithName(t *testing.T, baseURI, projectID string) (*app.CreateApplicationResponse, string) { + appName := gofakeit.AppName() + + appForOIDCConfigChange, appOIDCConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }) + require.Nil(t, appOIDCConfigChangeErr) + + return appForOIDCConfigChange, appName +} + +func createOIDCApp(t *testing.T, baseURI, projctID string) *app.CreateApplicationResponse { + app, _ := createOIDCAppWithName(t, baseURI, projctID) + + return app +} + +func createAPIAppWithName(t *testing.T, ctx context.Context, inst *integration.Instance, projectID string) (*app.CreateApplicationResponse, string) { + appName := gofakeit.AppName() + + reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appForAPIConfigChange, appAPIConfigChangeErr := inst.Client.AppV2Beta.CreateApplication(ctx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + return appForAPIConfigChange, appName +} + +func createAPIApp(t *testing.T, ctx context.Context, inst *integration.Instance, projectID string) *app.CreateApplicationResponse { + res, _ := createAPIAppWithName(t, ctx, inst, projectID) + return res +} + +func deactivateApp(t *testing.T, appToDeactivate *app.CreateApplicationResponse, projectID string) { + _, appDeactivateErr := instance.Client.AppV2Beta.DeactivateApplication(IAMOwnerCtx, &app.DeactivateApplicationRequest{ + ProjectId: projectID, + Id: appToDeactivate.GetAppId(), + }) + require.Nil(t, appDeactivateErr) +} + +func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(t, err) + + if f.PermissionCheckV2.GetEnabled() { + return + } + + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: gu.Ptr(true), + }) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{Inheritance: true}) + require.NoError(tt, err) + assert.True(tt, f.PermissionCheckV2.GetEnabled()) + }, retryDuration, tick, "timed out waiting for ensuring instance feature") +} + +func createAppKey(t *testing.T, ctx context.Context, inst *integration.Instance, projectID, appID string, expirationDate time.Time) *app.CreateApplicationKeyResponse { + res, err := inst.Client.AppV2Beta.CreateApplicationKey(ctx, + &app.CreateApplicationKeyRequest{ + AppId: appID, + ProjectId: projectID, + ExpirationDate: timestamppb.New(expirationDate.UTC()), + }, + ) + + require.Nil(t, err) + + return res +} diff --git a/internal/api/grpc/app/v2beta/query.go b/internal/api/grpc/app/v2beta/query.go new file mode 100644 index 0000000000..ab2a98d14a --- /dev/null +++ b/internal/api/grpc/app/v2beta/query.go @@ -0,0 +1,77 @@ +package app + +import ( + "context" + "strings" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) GetApplication(ctx context.Context, req *connect.Request[app.GetApplicationRequest]) (*connect.Response[app.GetApplicationResponse], error) { + res, err := s.query.AppByIDWithPermission(ctx, req.Msg.GetId(), false, s.checkPermission) + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.GetApplicationResponse{ + App: convert.AppToPb(res), + }), nil +} + +func (s *Server) ListApplications(ctx context.Context, req *connect.Request[app.ListApplicationsRequest]) (*connect.Response[app.ListApplicationsResponse], error) { + queries, err := convert.ListApplicationsRequestToModel(s.systemDefaults, req.Msg) + if err != nil { + return nil, err + } + + res, err := s.query.SearchApps(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.ListApplicationsResponse{ + Applications: convert.AppsToPb(res.Apps), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), + }), nil +} + +func (s *Server) GetApplicationKey(ctx context.Context, req *connect.Request[app.GetApplicationKeyRequest]) (*connect.Response[app.GetApplicationKeyResponse], error) { + queries, err := convert.GetApplicationKeyQueriesRequestToDomain(req.Msg.GetOrganizationId(), req.Msg.GetProjectId(), req.Msg.GetApplicationId()) + if err != nil { + return nil, err + } + + key, err := s.query.GetAuthNKeyByIDWithPermission(ctx, true, strings.TrimSpace(req.Msg.GetId()), s.checkPermission, queries...) + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.GetApplicationKeyResponse{ + Id: key.ID, + CreationDate: timestamppb.New(key.CreationDate), + ExpirationDate: timestamppb.New(key.Expiration), + }), nil +} + +func (s *Server) ListApplicationKeys(ctx context.Context, req *connect.Request[app.ListApplicationKeysRequest]) (*connect.Response[app.ListApplicationKeysResponse], error) { + queries, err := convert.ListApplicationKeysRequestToDomain(s.systemDefaults, req.Msg) + if err != nil { + return nil, err + } + + res, err := s.query.SearchAuthNKeys(ctx, queries, query.JoinFilterUnspecified, s.checkPermission) + if err != nil { + return nil, err + } + + return connect.NewResponse(&app.ListApplicationKeysResponse{ + Keys: convert.ApplicationKeysToPb(res.AuthNKeys), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), + }), nil +} diff --git a/internal/api/grpc/app/v2beta/server.go b/internal/api/grpc/app/v2beta/server.go new file mode 100644 index 0000000000..54842070cb --- /dev/null +++ b/internal/api/grpc/app/v2beta/server.go @@ -0,0 +1,59 @@ +package app + +import ( + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/app/v2beta/appconnect" +) + +var _ appconnect.AppServiceHandler = (*Server)(nil) + +type Server struct { + command *command.Commands + query *query.Queries + systemDefaults systemdefaults.SystemDefaults + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + command: command, + query: query, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return appconnect.NewAppServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return app.File_zitadel_app_v2beta_app_service_proto +} + +func (s *Server) AppName() string { + return app.AppService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return app.AppService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return app.AppService_AuthMethods +} diff --git a/internal/api/grpc/auth/user.go b/internal/api/grpc/auth/user.go index 13f955fd81..4015b0d370 100644 --- a/internal/api/grpc/auth/user.go +++ b/internal/api/grpc/auth/user.go @@ -32,7 +32,7 @@ func (s *Server) RemoveMyUser(ctx context.Context, _ *auth_pb.RemoveMyUserReques return nil, err } queries := &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantUserID}} - grants, err := s.query.UserGrants(ctx, queries, true) + grants, err := s.query.UserGrants(ctx, queries, true, nil) if err != nil { return nil, err } @@ -97,7 +97,7 @@ func (s *Server) ListMyMetadata(ctx context.Context, req *auth_pb.ListMyMetadata if err != nil { return nil, err } - res, err := s.query.SearchUserMetadata(ctx, true, authz.GetCtxData(ctx).UserID, queries, false) + res, err := s.query.SearchUserMetadata(ctx, true, authz.GetCtxData(ctx).UserID, queries, nil) if err != nil { return nil, err } @@ -151,7 +151,7 @@ func (s *Server) ListMyUserGrants(ctx context.Context, req *auth_pb.ListMyUserGr if err != nil { return nil, err } - res, err := s.query.UserGrants(ctx, queries, false) + res, err := s.query.UserGrants(ctx, queries, false, nil) if err != nil { return nil, err } @@ -180,7 +180,7 @@ func (s *Server) ListMyProjectOrgs(ctx context.Context, req *auth_pb.ListMyProje return nil, err } - grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantProjectID, userGrantUserID}}, false) + grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantProjectID, userGrantUserID}}, false, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/authorization/v2beta/authorization.go b/internal/api/grpc/authorization/v2beta/authorization.go new file mode 100644 index 0000000000..f5410c959a --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/authorization.go @@ -0,0 +1,76 @@ +package authorization + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" +) + +func (s *Server) CreateAuthorization(ctx context.Context, req *connect.Request[authorization.CreateAuthorizationRequest]) (*connect.Response[authorization.CreateAuthorizationResponse], error) { + grant := &domain.UserGrant{ + UserID: req.Msg.UserId, + ProjectID: req.Msg.ProjectId, + RoleKeys: req.Msg.RoleKeys, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: req.Msg.GetOrganizationId(), + }, + } + grant, err := s.command.AddUserGrant(ctx, grant, s.command.NewPermissionCheckUserGrantWrite(ctx)) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.CreateAuthorizationResponse{ + Id: grant.AggregateID, + CreationDate: timestamppb.New(grant.ChangeDate), + }), nil +} + +func (s *Server) UpdateAuthorization(ctx context.Context, request *connect.Request[authorization.UpdateAuthorizationRequest]) (*connect.Response[authorization.UpdateAuthorizationResponse], error) { + userGrant, err := s.command.ChangeUserGrant(ctx, &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: request.Msg.Id, + }, + RoleKeys: request.Msg.RoleKeys, + }, true, true, s.command.NewPermissionCheckUserGrantWrite(ctx)) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.UpdateAuthorizationResponse{ + ChangeDate: timestamppb.New(userGrant.ChangeDate), + }), nil +} + +func (s *Server) DeleteAuthorization(ctx context.Context, request *connect.Request[authorization.DeleteAuthorizationRequest]) (*connect.Response[authorization.DeleteAuthorizationResponse], error) { + details, err := s.command.RemoveUserGrant(ctx, request.Msg.Id, "", true, s.command.NewPermissionCheckUserGrantDelete(ctx)) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.DeleteAuthorizationResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) ActivateAuthorization(ctx context.Context, request *connect.Request[authorization.ActivateAuthorizationRequest]) (*connect.Response[authorization.ActivateAuthorizationResponse], error) { + details, err := s.command.ReactivateUserGrant(ctx, request.Msg.Id, "", s.command.NewPermissionCheckUserGrantWrite(ctx)) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.ActivateAuthorizationResponse{ + ChangeDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) DeactivateAuthorization(ctx context.Context, request *connect.Request[authorization.DeactivateAuthorizationRequest]) (*connect.Response[authorization.DeactivateAuthorizationResponse], error) { + details, err := s.command.DeactivateUserGrant(ctx, request.Msg.Id, "", s.command.NewPermissionCheckUserGrantWrite(ctx)) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.DeactivateAuthorizationResponse{ + ChangeDate: timestamppb.New(details.EventDate), + }), nil +} diff --git a/internal/api/grpc/authorization/v2beta/integration_test/authorization_test.go b/internal/api/grpc/authorization/v2beta/integration_test/authorization_test.go new file mode 100644 index 0000000000..d24844f2a2 --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/integration_test/authorization_test.go @@ -0,0 +1,1023 @@ +//go:build integration + +package authorization_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/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/integration" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" + project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_CreateAuthorization(t *testing.T) { + type args struct { + prepare func(*testing.T, *authorization.CreateAuthorizationRequest) context.Context + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "add authorization, project owned, PROJECT_OWNER, ok", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "add authorization, project owned, PROJECT_OWNER, no org id, ok", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "add authorization, project owned, ORG_OWNER, ok", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateOrgMembership(t, IAMCTX, selfOrgId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "add authorization, project owned, no permission, error", + args: args{ + + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "add authorization, role does not exist, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "add authorization, project does not exist, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + request.ProjectId = gofakeit.AppName() + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "add authorization, org does not exist, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = gu.Ptr(gofakeit.AppName()) + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + + }, + }, + wantErr: true, + }, + { + name: "add authorization, project owner, project granted, no permission", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + foreignOrg := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()) + request.ProjectId = Instance.CreateProject(IAMCTX, t, foreignOrg.OrganizationId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + Instance.CreateProjectGrant(IAMCTX, t, request.ProjectId, selfOrgId, request.RoleKeys...) + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "add authorization, role key not granted, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + foreignOrg := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()) + request.ProjectId = Instance.CreateProject(IAMCTX, t, foreignOrg.OrganizationId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + Instance.CreateProjectGrant(IAMCTX, t, request.ProjectId, selfOrgId) + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "add authorization, grant does not exist, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + foreignOrg := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()) + projectID := Instance.CreateProject(IAMCTX, t, foreignOrg.OrganizationId, gofakeit.AppName(), false, false).Id + request.ProjectId = projectID + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, projectID, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectID, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + + }, + }, + wantErr: true, + }, + { + name: "add authorization, PROJECT_OWNER on wrong org, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + foreignOrg := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()) + request.ProjectId = Instance.CreateProject(IAMCTX, t, foreignOrg.OrganizationId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrg.OrganizationId}), request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + req := &authorization.CreateAuthorizationRequest{} + ctx := tt.args.prepare(t, req) + got, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.Id, "id is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_UpdateAuthorization(t *testing.T) { + type args struct { + prepare func(*testing.T, *authorization.UpdateAuthorizationRequest) context.Context + } + tests := []struct { + name string + args args + wantErr bool + wantChangedDateDuringPrepare bool + }{ + { + name: "update authorization, owned project, ok", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectRole1} + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "update authorization, owned project, role not found, error", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectRole1, projectRole2, gofakeit.AppName()} + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "update authorization, owned project, unchanged, ok, changed date is creation date", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectRole1, projectRole2} + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantChangedDateDuringPrepare: true, + }, + { + name: "update authorization, granted project, ok", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectRole1} + token := createUserWithProjectGrantMembership(IAMCTX, t, Instance, projectId, selfOrgId) + return integration.WithAuthorizationToken(EmptyCTX, token) + + }, + }, + }, + { + name: "update authorization, granted project, role not granted, error", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectId, projectRole3, projectRole3} + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectGrantMembership(t, IAMCTX, projectId, selfOrgId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "update authorization, granted project, grant removed, error", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectRole1} + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectGrantMembership(t, IAMCTX, projectId, selfOrgId, callingUser.Id) + _, err = Instance.Client.Projectv2Beta.DeleteProjectGrant(IAMCTX, &project.DeleteProjectGrantRequest{ + ProjectId: projectId, + GrantedOrganizationId: selfOrgId, + }) + require.NoError(t, err) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + req := &authorization.UpdateAuthorizationRequest{} + ctx := tt.args.prepare(t, req) + afterPrepare := time.Now() + got, err := Instance.Client.AuthorizationV2Beta.UpdateAuthorization(ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.ChangeDate, "change date is empty") + changeDate := got.ChangeDate.AsTime() + assert.Greater(t, changeDate, now, "change date is before the test started") + if tt.wantChangedDateDuringPrepare { + assert.Less(t, changeDate, afterPrepare, "change date is after prepare finished") + } else { + assert.Less(t, changeDate, time.Now(), "change date is in the future") + } + }) + } +} + +func TestServer_DeleteAuthorization(t *testing.T) { + type args struct { + prepare func(*testing.T, *authorization.DeleteAuthorizationRequest) context.Context + } + tests := []struct { + name string + args args + wantErr bool + wantDeletionDateDuringPrepare bool + }{ + { + name: "delete authorization, project owned by calling users org, ok", + args: args{ + func(t *testing.T, request *authorization.DeleteAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, { + name: "delete authorization, owned project, user membership on project owning org, ok", + args: args{ + func(t *testing.T, request *authorization.DeleteAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "delete authorization, granted project, user membership on project owning org, error", + args: args{ + func(t *testing.T, request *authorization.DeleteAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "delete authorization, granted project, user membership on project granted org, ok", + args: args{ + func(t *testing.T, request *authorization.DeleteAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + return integration.WithAuthorizationToken(EmptyCTX, createUserWithProjectGrantMembership(IAMCTX, t, Instance, projectId, selfOrgId)) + + }, + }, + }, + { + name: "delete authorization, already deleted, ok, deletion date is creation date", + args: args{ + func(t *testing.T, request *authorization.DeleteAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + _, err = Instance.Client.AuthorizationV2Beta.DeleteAuthorization(IAMCTX, &authorization.DeleteAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantDeletionDateDuringPrepare: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + req := &authorization.DeleteAuthorizationRequest{} + ctx := tt.args.prepare(t, req) + afterPrepare := time.Now() + got, err := Instance.Client.AuthorizationV2Beta.DeleteAuthorization(ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.DeletionDate, "deletion date is empty") + changeDate := got.DeletionDate.AsTime() + assert.Greater(t, changeDate, now, "deletion date is before the test started") + if tt.wantDeletionDateDuringPrepare { + assert.Less(t, changeDate, afterPrepare, "deletion date is after prepare finished") + } else { + assert.Less(t, changeDate, time.Now(), "deletion date is in the future") + } + }) + } +} + +func TestServer_DeactivateAuthorization(t *testing.T) { + type args struct { + prepare func(*testing.T, *authorization.DeactivateAuthorizationRequest) context.Context + } + tests := []struct { + name string + args args + wantErr bool + wantDeletionDateDuringPrepare bool + }{ + { + name: "deactivate authorization, project owned by calling users org, ok", + args: args{ + func(t *testing.T, request *authorization.DeactivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, { + name: "deactivate authorization, owned project, user membership on project owning org, ok", + args: args{ + func(t *testing.T, request *authorization.DeactivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "deactivate authorization, granted project, user membership on project owning org, error", + args: args{ + func(t *testing.T, request *authorization.DeactivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "deactivate authorization, granted project, user membership on project granted org, ok", + args: args{ + func(t *testing.T, request *authorization.DeactivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + return integration.WithAuthorizationToken(EmptyCTX, createUserWithProjectGrantMembership(IAMCTX, t, Instance, projectId, selfOrgId)) + }, + }, + }, + { + name: "deactivate authorization, already inactive, ok, change date is creation date", + args: args{ + func(t *testing.T, request *authorization.DeactivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + _, err = Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(IAMCTX, &authorization.DeactivateAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantDeletionDateDuringPrepare: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + req := &authorization.DeactivateAuthorizationRequest{} + ctx := tt.args.prepare(t, req) + afterPrepare := time.Now() + got, err := Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.ChangeDate, "change date is empty") + changeDate := got.ChangeDate.AsTime() + assert.Greater(t, changeDate, now, "change date is before the test started") + if tt.wantDeletionDateDuringPrepare { + assert.Less(t, changeDate, afterPrepare, "change date is after prepare finished") + } else { + assert.Less(t, changeDate, time.Now(), "change date is in the future") + } + }) + } +} + +func TestServer_ActivateAuthorization(t *testing.T) { + type args struct { + prepare func(*testing.T, *authorization.ActivateAuthorizationRequest) context.Context + } + tests := []struct { + name string + args args + wantErr bool + wantDeletionDateDuringPrepare bool + }{ + { + name: "activate authorization, project owned by calling users org, ok", + args: args{ + func(t *testing.T, request *authorization.ActivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + _, err = Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(IAMCTX, &authorization.DeactivateAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, { + name: "activate authorization, owned project, user membership on project owning org, ok", + args: args{ + func(t *testing.T, request *authorization.ActivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + _, err = Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(IAMCTX, &authorization.DeactivateAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "activate authorization, granted project, user membership on project owning org, error", + args: args{ + func(t *testing.T, request *authorization.ActivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + _, err = Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(IAMCTX, &authorization.DeactivateAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "activate authorization, granted project, user membership on project granted org, ok", + args: args{ + func(t *testing.T, request *authorization.ActivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + _, err = Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(IAMCTX, &authorization.DeactivateAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, createUserWithProjectGrantMembership(IAMCTX, t, Instance, projectId, selfOrgId)) + }, + }, + }, + { + name: "activate authorization, already active, ok, change date is creation date", + args: args{ + func(t *testing.T, request *authorization.ActivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantDeletionDateDuringPrepare: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + req := &authorization.ActivateAuthorizationRequest{} + ctx := tt.args.prepare(t, req) + afterPrepare := time.Now() + got, err := Instance.Client.AuthorizationV2Beta.ActivateAuthorization(ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.ChangeDate, "change date is empty") + changeDate := got.ChangeDate.AsTime() + assert.Greater(t, changeDate, now, "change date is before the test started") + if tt.wantDeletionDateDuringPrepare { + assert.Less(t, changeDate, afterPrepare, "change date is after prepare finished") + } else { + assert.Less(t, changeDate, time.Now(), "change date is in the future") + } + }) + } +} + +func createUserWithProjectGrantMembership(ctx context.Context, t *testing.T, instance *integration.Instance, projectID, grantID string) string { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + callingUser := instance.CreateUserTypeMachine(ctx, selfOrgId) + instance.CreateProjectGrantMembership(t, ctx, projectID, grantID, callingUser.Id) + token, err := instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 10*time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + got, err := instance.Client.AuthorizationV2Beta.ListAuthorizations(ctx, &authorization.ListAuthorizationsRequest{ + Filters: nil, + }) + assert.NoError(tt, err) + if !assert.NotEmpty(tt, got.Pagination.TotalResult) { + return + } + }, retryDuration, tick) + return token.GetToken() +} diff --git a/internal/api/grpc/authorization/v2beta/integration_test/query_test.go b/internal/api/grpc/authorization/v2beta/integration_test/query_test.go new file mode 100644 index 0000000000..fd0a807c1d --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/integration_test/query_test.go @@ -0,0 +1,971 @@ +//go:build integration + +package authorization_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_ListAuthorizations(t *testing.T) { + iamOwnerCtx := Instance.WithAuthorizationToken(EmptyCTX, integration.UserTypeIAMOwner) + projectOwnerResp := Instance.CreateMachineUser(iamOwnerCtx) + projectOwnerPatResp := Instance.CreatePersonalAccessToken(iamOwnerCtx, projectOwnerResp.GetUserId()) + projectResp := createProject(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), false, false) + Instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), projectOwnerResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(EmptyCTX, projectOwnerPatResp.Token) + + projectGrantOwnerResp := Instance.CreateMachineUser(iamOwnerCtx) + projectGrantOwnerPatResp := Instance.CreatePersonalAccessToken(iamOwnerCtx, projectGrantOwnerResp.GetUserId()) + grantedProjectResp := createGrantedProject(iamOwnerCtx, Instance, t, projectResp) + Instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), grantedProjectResp.GetGrantedOrganizationId(), projectGrantOwnerResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(EmptyCTX, projectGrantOwnerPatResp.Token) + + type args struct { + ctx context.Context + dep func(*authorization.ListAuthorizationsRequest, *authorization.ListAuthorizationsResponse) + req *authorization.ListAuthorizationsRequest + } + tests := []struct { + name string + args args + want *authorization.ListAuthorizationsResponse + wantErr bool + }{ + { + name: "list by user id, unauthenticated", + args: args{ + ctx: EmptyCTX, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: Instance.WithAuthorizationToken(EmptyCTX, integration.UserTypeNoPermission), + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{ + {Filter: &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: "notexisting", + }, + }, + }, + }, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[0] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_AuthorizationIds{ + AuthorizationIds: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single project id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectId{ + ProjectId: &filter.IDFilter{ + Id: resp.GetProjectId(), + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single project name", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectName{ + ProjectName: &authorization.ProjectNameQuery{ + Name: resp.GetProjectName(), + Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[0] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), true) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single grant id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), true) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectGrantId{ + ProjectGrantId: &filter.IDFilter{ + Id: resp.GetProjectGrantId(), + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id, project and project grant, multiple", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[5] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[4] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[3] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[2] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), true) + response.Authorizations[1] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), true) + response.Authorizations[0] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), true) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 6, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, {}, {}, {}, {}, {}, + }, + }, + }, + { + name: "list single id, project and project grant, org owner", + args: args{ + ctx: Instance.WithAuthorizationToken(EmptyCTX, integration.UserTypeOrgOwner), + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + response.Authorizations[1] = createAuthorizationForProject(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + response.Authorizations[0] = createAuthorizationWithProjectGrant(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, {}, + }, + }, + }, + { + name: "list single id, project and project grant, project owner", + args: args{ + ctx: projectOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + response.Authorizations[0] = createAuthorizationForProject(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + createAuthorizationWithProjectGrant(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id, project and project grant, project grant owner", + args: args{ + ctx: projectGrantOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + createAuthorizationForProject(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + response.Authorizations[0] = createAuthorizationForProjectGrant(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId(), grantedProjectResp.GetGrantedOrganizationId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := Instance.Client.AuthorizationV2Beta.ListAuthorizations(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Authorizations, len(tt.want.Authorizations)) { + for i := range tt.want.Authorizations { + assert.EqualExportedValues(ttt, tt.want.Authorizations[i], got.Authorizations[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +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 createAuthorization(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, userID string, grant bool) *authorization.Authorization { + projectName := gofakeit.AppName() + projectResp := instance.CreateProject(ctx, t, orgID, projectName, false, false) + + if grant { + return createAuthorizationWithProjectGrant(ctx, instance, t, orgID, userID, projectName, projectResp.GetId()) + } + return createAuthorizationForProject(ctx, instance, t, orgID, userID, projectName, projectResp.GetId()) +} + +func createAuthorizationForProject(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, userID, projectName, projectID string) *authorization.Authorization { + userResp, err := instance.Client.UserV2.GetUserByID(ctx, &user.GetUserByIDRequest{UserId: userID}) + require.NoError(t, err) + + userGrantResp := instance.CreateProjectUserGrant(t, ctx, projectID, userID) + return &authorization.Authorization{ + Id: userGrantResp.GetUserGrantId(), + ProjectId: projectID, + ProjectName: projectName, + ProjectOrganizationId: orgID, + OrganizationId: orgID, + CreationDate: userGrantResp.Details.GetCreationDate(), + ChangeDate: userGrantResp.Details.GetCreationDate(), + State: 1, + User: &authorization.User{ + Id: userID, + PreferredLoginName: userResp.User.GetPreferredLoginName(), + DisplayName: userResp.User.GetHuman().GetProfile().GetDisplayName(), + AvatarUrl: userResp.User.GetHuman().GetProfile().GetAvatarUrl(), + OrganizationId: userResp.GetUser().GetDetails().GetResourceOwner(), + }, + } +} + +func createAuthorizationWithProjectGrant(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, userID, projectName, projectID string) *authorization.Authorization { + grantedOrgName := gofakeit.Company() + integration.RandString(10) + grantedOrg := instance.CreateOrganization(ctx, grantedOrgName, gofakeit.Email()) + instance.CreateProjectGrant(ctx, t, projectID, grantedOrg.GetOrganizationId()) + + return createAuthorizationForProjectGrant(ctx, instance, t, orgID, userID, projectName, projectID, grantedOrg.GetOrganizationId()) +} + +func createAuthorizationForProjectGrant(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, userID, projectName, projectID, grantedOrgID string) *authorization.Authorization { + userResp, err := instance.Client.UserV2.GetUserByID(ctx, &user.GetUserByIDRequest{UserId: userID}) + require.NoError(t, err) + + userGrantResp := instance.CreateProjectGrantUserGrant(ctx, orgID, projectID, grantedOrgID, userID) + return &authorization.Authorization{ + Id: userGrantResp.GetUserGrantId(), + ProjectId: projectID, + ProjectName: projectName, + ProjectOrganizationId: orgID, + ProjectGrantId: gu.Ptr(grantedOrgID), + GrantedOrganizationId: gu.Ptr(grantedOrgID), + OrganizationId: orgID, + CreationDate: userGrantResp.Details.GetCreationDate(), + ChangeDate: userGrantResp.Details.GetCreationDate(), + State: 1, + User: &authorization.User{ + Id: userID, + PreferredLoginName: userResp.User.GetPreferredLoginName(), + DisplayName: userResp.User.GetHuman().GetProfile().GetDisplayName(), + AvatarUrl: userResp.User.GetHuman().GetProfile().GetAvatarUrl(), + OrganizationId: userResp.GetUser().GetDetails().GetResourceOwner(), + }, + } +} + +func createProject(ctx context.Context, instance *integration.Instance, t *testing.T, orgID string, projectRoleCheck, hasProjectCheck bool) *project.Project { + name := gofakeit.AppName() + resp := instance.CreateProject(ctx, t, orgID, name, projectRoleCheck, hasProjectCheck) + return &project.Project{ + Id: resp.GetId(), + Name: name, + OrganizationId: orgID, + CreationDate: resp.GetCreationDate(), + ChangeDate: resp.GetCreationDate(), + State: 1, + ProjectRoleAssertion: false, + ProjectAccessRequired: hasProjectCheck, + AuthorizationRequired: projectRoleCheck, + PrivateLabelingSetting: project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED, + } +} + +func createGrantedProject(ctx context.Context, instance *integration.Instance, t *testing.T, projectToGrant *project.Project) *project.Project { + grantedOrgName := gofakeit.AppName() + grantedOrg := instance.CreateOrganization(ctx, grantedOrgName, gofakeit.Email()) + projectGrantResp := instance.CreateProjectGrant(ctx, t, projectToGrant.GetId(), grantedOrg.GetOrganizationId()) + + return &project.Project{ + Id: projectToGrant.GetId(), + Name: projectToGrant.GetName(), + OrganizationId: projectToGrant.GetOrganizationId(), + CreationDate: projectGrantResp.GetCreationDate(), + ChangeDate: projectGrantResp.GetCreationDate(), + State: 1, + ProjectRoleAssertion: false, + ProjectAccessRequired: projectToGrant.GetProjectAccessRequired(), + AuthorizationRequired: projectToGrant.GetAuthorizationRequired(), + PrivateLabelingSetting: project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED, + GrantedOrganizationId: gu.Ptr(grantedOrg.GetOrganizationId()), + GrantedOrganizationName: gu.Ptr(grantedOrgName), + GrantedState: 1, + } +} + +func TestServer_ListAuthorizations_PermissionsV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, InstancePermissionV2) + iamOwnerCtx := InstancePermissionV2.WithAuthorizationToken(EmptyCTX, integration.UserTypeIAMOwner) + + projectOwnerResp := InstancePermissionV2.CreateMachineUser(iamOwnerCtx) + projectOwnerPatResp := InstancePermissionV2.CreatePersonalAccessToken(iamOwnerCtx, projectOwnerResp.GetUserId()) + projectResp := createProject(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), false, false) + InstancePermissionV2.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), projectOwnerResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(EmptyCTX, projectOwnerPatResp.Token) + + //projectGrantOwnerResp := InstancePermissionV2.CreateMachineUser(iamOwnerCtx) + //projectGrantOwnerPatResp := InstancePermissionV2.CreatePersonalAccessToken(iamOwnerCtx, projectGrantOwnerResp.GetUserId()) + grantedProjectResp := createGrantedProject(iamOwnerCtx, InstancePermissionV2, t, projectResp) + //InstancePermissionV2.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), grantedProjectResp.GetGrantedOrganizationId(), projectGrantOwnerResp.GetUserId()) + //projectGrantOwnerCtx := integration.WithAuthorizationToken(EmptyCTX, projectGrantOwnerPatResp.Token) + + type args struct { + ctx context.Context + dep func(*authorization.ListAuthorizationsRequest, *authorization.ListAuthorizationsResponse) + req *authorization.ListAuthorizationsRequest + } + tests := []struct { + name string + args args + want *authorization.ListAuthorizationsResponse + wantErr bool + }{ + { + name: "list by user id, unauthenticated", + args: args{ + ctx: EmptyCTX, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: InstancePermissionV2.WithAuthorizationToken(EmptyCTX, integration.UserTypeNoPermission), + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{ + {Filter: &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: "notexisting", + }, + }, + }, + }, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[0] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_AuthorizationIds{ + AuthorizationIds: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single project id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectId{ + ProjectId: &filter.IDFilter{ + Id: resp.GetProjectId(), + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single project name", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectName{ + ProjectName: &authorization.ProjectNameQuery{ + Name: resp.GetProjectName(), + Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[0] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), true) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single grant id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), true) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectGrantId{ + ProjectGrantId: &filter.IDFilter{ + Id: resp.GetProjectGrantId(), + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id, project and project grant, multiple", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[5] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[4] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[3] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[2] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), true) + response.Authorizations[1] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), true) + response.Authorizations[0] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), true) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 6, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, {}, {}, {}, {}, {}, + }, + }, + }, + { + name: "list single id, project and project grant, org owner", + args: args{ + ctx: InstancePermissionV2.WithAuthorizationToken(EmptyCTX, integration.UserTypeOrgOwner), + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + response.Authorizations[1] = createAuthorizationForProject(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + response.Authorizations[0] = createAuthorizationWithProjectGrant(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, {}, + }, + }, + }, + { + name: "list single id, project and project grant, project owner", + args: args{ + ctx: projectOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + response.Authorizations[1] = createAuthorizationForProject(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + response.Authorizations[0] = createAuthorizationWithProjectGrant(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, {}, + }, + }, + }, + /* + TODO: correct when permission check is added for project grants https://github.com/zitadel/zitadel/issues/9972 + { + name: "list single id, project and project grant, project grant owner", + args: args{ + ctx: projectGrantOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + createAuthorizationForProject(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + response.Authorizations[0] = createAuthorizationForProjectGrant(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + */ + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := InstancePermissionV2.Client.AuthorizationV2Beta.ListAuthorizations(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Authorizations, len(tt.want.Authorizations)) { + for i := range tt.want.Authorizations { + assert.EqualExportedValues(ttt, tt.want.Authorizations[i], got.Authorizations[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} diff --git a/internal/api/grpc/authorization/v2beta/integration_test/server_test.go b/internal/api/grpc/authorization/v2beta/integration_test/server_test.go new file mode 100644 index 0000000000..fc59713708 --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/integration_test/server_test.go @@ -0,0 +1,65 @@ +//go:build integration + +package authorization_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" +) + +var ( + EmptyCTX context.Context + IAMCTX context.Context + Instance *integration.Instance + InstancePermissionV2 *integration.Instance +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + EmptyCTX = ctx + Instance = integration.NewInstance(ctx) + IAMCTX = Instance.WithAuthorizationToken(ctx, integration.UserTypeIAMOwner) + InstancePermissionV2 = integration.NewInstance(ctx) + return m.Run() + }()) +} + +func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorizationToken(EmptyCTX, integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(t, err) + if f.PermissionCheckV2.GetEnabled() { + return + } + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: gu.Ptr(true), + }) + require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, + func(ttt *assert.CollectT) { + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + assert.NoError(ttt, err) + if f.PermissionCheckV2.GetEnabled() { + return + } + }, + retryDuration, + tick, + "timed out waiting for ensuring instance feature") +} diff --git a/internal/api/grpc/authorization/v2beta/query.go b/internal/api/grpc/authorization/v2beta/query.go new file mode 100644 index 0000000000..75c3d67178 --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/query.go @@ -0,0 +1,208 @@ +package authorization + +import ( + "context" + "errors" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" +) + +func (s *Server) ListAuthorizations(ctx context.Context, req *connect.Request[authorization.ListAuthorizationsRequest]) (*connect.Response[authorization.ListAuthorizationsResponse], error) { + queries, err := s.listAuthorizationsRequestToModel(req.Msg) + if err != nil { + return nil, err + } + resp, err := s.query.UserGrants(ctx, queries, false, s.checkPermission) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.ListAuthorizationsResponse{ + Authorizations: userGrantsToPb(resp.UserGrants), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), + }), nil +} + +func (s *Server) listAuthorizationsRequestToModel(req *authorization.ListAuthorizationsRequest) (*query.UserGrantsQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := AuthorizationQueriesToQuery(req.Filters) + if err != nil { + return nil, err + } + return &query.UserGrantsQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: authorizationFieldNameToSortingColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func authorizationFieldNameToSortingColumn(field authorization.AuthorizationFieldName) query.Column { + switch field { + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_UNSPECIFIED: + return query.UserGrantCreationDate + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_CREATED_DATE: + return query.UserGrantCreationDate + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_CHANGED_DATE: + return query.UserGrantChangeDate + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_ID: + return query.UserGrantID + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_USER_ID: + return query.UserGrantUserID + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_PROJECT_ID: + return query.UserGrantProjectID + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_ORGANIZATION_ID: + return query.UserGrantResourceOwner + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_USER_ORGANIZATION_ID: + return query.UserResourceOwnerCol + default: + return query.UserGrantCreationDate + } +} + +func AuthorizationQueriesToQuery(queries []*authorization.AuthorizationsSearchFilter) (q []query.SearchQuery, err error) { + q = make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = AuthorizationSearchFilterToQuery(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func AuthorizationSearchFilterToQuery(query *authorization.AuthorizationsSearchFilter) (query.SearchQuery, error) { + switch q := query.Filter.(type) { + case *authorization.AuthorizationsSearchFilter_AuthorizationIds: + return AuthorizationIDQueryToModel(q.AuthorizationIds) + case *authorization.AuthorizationsSearchFilter_OrganizationId: + return AuthorizationOrganizationIDQueryToModel(q.OrganizationId) + case *authorization.AuthorizationsSearchFilter_State: + return AuthorizationStateQueryToModel(q.State) + case *authorization.AuthorizationsSearchFilter_UserId: + return AuthorizationUserUserIDQueryToModel(q.UserId) + case *authorization.AuthorizationsSearchFilter_UserOrganizationId: + return AuthorizationUserOrganizationIDQueryToModel(q.UserOrganizationId) + case *authorization.AuthorizationsSearchFilter_UserPreferredLoginName: + return AuthorizationUserNameQueryToModel(q.UserPreferredLoginName) + case *authorization.AuthorizationsSearchFilter_UserDisplayName: + return AuthorizationDisplayNameQueryToModel(q.UserDisplayName) + case *authorization.AuthorizationsSearchFilter_ProjectId: + return AuthorizationProjectIDQueryToModel(q.ProjectId) + case *authorization.AuthorizationsSearchFilter_ProjectName: + return AuthorizationProjectNameQueryToModel(q.ProjectName) + case *authorization.AuthorizationsSearchFilter_RoleKey: + return AuthorizationRoleKeyQueryToModel(q.RoleKey) + case *authorization.AuthorizationsSearchFilter_ProjectGrantId: + return AuthorizationProjectGrantIDQueryToModel(q.ProjectGrantId) + default: + return nil, errors.New("invalid query") + } +} + +func AuthorizationIDQueryToModel(q *filter_pb.InIDsFilter) (query.SearchQuery, error) { + return query.NewUserGrantInIDsSearchQuery(q.Ids) +} + +func AuthorizationDisplayNameQueryToModel(q *authorization.UserDisplayNameQuery) (query.SearchQuery, error) { + return query.NewUserGrantDisplayNameQuery(q.DisplayName, filter.TextMethodPbToQuery(q.Method)) +} + +func AuthorizationOrganizationIDQueryToModel(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewUserGrantResourceOwnerSearchQuery(q.Id) +} + +func AuthorizationProjectIDQueryToModel(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewUserGrantProjectIDSearchQuery(q.Id) +} + +func AuthorizationProjectNameQueryToModel(q *authorization.ProjectNameQuery) (query.SearchQuery, error) { + return query.NewUserGrantProjectNameQuery(q.Name, filter.TextMethodPbToQuery(q.Method)) +} + +func AuthorizationProjectGrantIDQueryToModel(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewUserGrantGrantIDSearchQuery(q.Id) +} + +func AuthorizationRoleKeyQueryToModel(q *authorization.RoleKeyQuery) (query.SearchQuery, error) { + return query.NewUserGrantRoleQuery(q.Key) +} + +func AuthorizationUserNameQueryToModel(q *authorization.UserPreferredLoginNameQuery) (query.SearchQuery, error) { + return query.NewUserGrantUsernameQuery(q.LoginName, filter.TextMethodPbToQuery(q.Method)) +} + +func AuthorizationUserUserIDQueryToModel(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewUserGrantUserIDSearchQuery(q.Id) +} + +func AuthorizationUserOrganizationIDQueryToModel(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewUserGrantUserResourceOwnerSearchQuery(q.Id) +} + +func AuthorizationStateQueryToModel(q *authorization.StateQuery) (query.SearchQuery, error) { + return query.NewUserGrantStateQuery(domain.UserGrantState(q.State)) +} + +func userGrantsToPb(userGrants []*query.UserGrant) []*authorization.Authorization { + o := make([]*authorization.Authorization, len(userGrants)) + for i, grant := range userGrants { + o[i] = userGrantToPb(grant) + } + return o +} + +func userGrantToPb(userGrant *query.UserGrant) *authorization.Authorization { + var grantID, grantedOrgID *string + if userGrant.GrantID != "" { + grantID = &userGrant.GrantID + } + if userGrant.GrantedOrgID != "" { + grantedOrgID = &userGrant.GrantedOrgID + } + return &authorization.Authorization{ + Id: userGrant.ID, + ProjectId: userGrant.ProjectID, + ProjectName: userGrant.ProjectName, + ProjectOrganizationId: userGrant.ProjectResourceOwner, + ProjectGrantId: grantID, + GrantedOrganizationId: grantedOrgID, + OrganizationId: userGrant.ResourceOwner, + CreationDate: timestamppb.New(userGrant.CreationDate), + ChangeDate: timestamppb.New(userGrant.ChangeDate), + State: userGrantStateToPb(userGrant.State), + User: &authorization.User{ + Id: userGrant.UserID, + PreferredLoginName: userGrant.PreferredLoginName, + DisplayName: userGrant.DisplayName, + AvatarUrl: userGrant.AvatarURL, + OrganizationId: userGrant.UserResourceOwner, + }, + Roles: userGrant.Roles, + } +} + +func userGrantStateToPb(state domain.UserGrantState) authorization.State { + switch state { + case domain.UserGrantStateActive: + return authorization.State_STATE_ACTIVE + case domain.UserGrantStateInactive: + return authorization.State_STATE_INACTIVE + case domain.UserGrantStateUnspecified, domain.UserGrantStateRemoved: + return authorization.State_STATE_UNSPECIFIED + default: + return authorization.State_STATE_UNSPECIFIED + } +} diff --git a/internal/api/grpc/authorization/v2beta/server.go b/internal/api/grpc/authorization/v2beta/server.go new file mode 100644 index 0000000000..4d66309d2a --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/server.go @@ -0,0 +1,67 @@ +package authorization + +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/domain" + "github.com/zitadel/zitadel/internal/query" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta/authorizationconnect" +) + +var _ authorizationconnect.AuthorizationServiceHandler = (*Server)(nil) + +type Server struct { + systemDefaults systemdefaults.SystemDefaults + command *command.Commands + query *query.Queries + + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + systemDefaults systemdefaults.SystemDefaults, + command *command.Commands, + query *query.Queries, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + systemDefaults: systemDefaults, + command: command, + query: query, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return authorizationconnect.NewAuthorizationServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return authorization.File_zitadel_authorization_v2beta_authorization_service_proto +} + +func (s *Server) AppName() string { + return authorization.AuthorizationService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return authorization.AuthorizationService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return authorization.AuthorizationService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return authorization.RegisterAuthorizationServiceHandler +} diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index e146ac2db6..ab8ddc7d75 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -18,34 +18,30 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command return nil, err } return &command.SystemFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, - DisableUserTokenEvent: req.DisableUserTokenEvent, - EnableBackChannelLogout: req.EnableBackChannelLogout, - LoginV2: loginV2, - PermissionCheckV2: req.PermissionCheckV2, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + DisableUserTokenEvent: req.DisableUserTokenEvent, + EnableBackChannelLogout: req.EnableBackChannelLogout, + LoginV2: loginV2, + PermissionCheckV2: req.PermissionCheckV2, }, nil } func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesResponse { return &feature_pb.GetSystemFeaturesResponse{ - Details: object.DomainToDetailsPb(f.Details), - LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), - OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), - DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), - EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), - LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), - PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), + EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), + LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), + PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), } } @@ -55,40 +51,34 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com return nil, err } return &command.InstanceFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - WebKey: req.WebKey, - DebugOIDCParentError: req.DebugOidcParentError, - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, - DisableUserTokenEvent: req.DisableUserTokenEvent, - EnableBackChannelLogout: req.EnableBackChannelLogout, - LoginV2: loginV2, - PermissionCheckV2: req.PermissionCheckV2, - ConsoleUseV2UserApi: req.ConsoleUseV2UserApi, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + DebugOIDCParentError: req.DebugOidcParentError, + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + DisableUserTokenEvent: req.DisableUserTokenEvent, + EnableBackChannelLogout: req.EnableBackChannelLogout, + LoginV2: loginV2, + PermissionCheckV2: req.PermissionCheckV2, + ConsoleUseV2UserApi: req.ConsoleUseV2UserApi, }, nil } func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeaturesResponse { return &feature_pb.GetInstanceFeaturesResponse{ - Details: object.DomainToDetailsPb(f.Details), - LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), - OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - WebKey: featureSourceToFlagPb(&f.WebKey), - DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), - DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), - EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), - LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), - PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), - ConsoleUseV2UserApi: featureSourceToFlagPb(&f.ConsoleUseV2UserApi), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), + EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), + LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), + PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), + ConsoleUseV2UserApi: featureSourceToFlagPb(&f.ConsoleUseV2UserApi), } } diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index f481e4f65a..7b11fc0d17 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -19,26 +19,22 @@ import ( func Test_systemFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetSystemFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - OidcLegacyIntrospection: nil, - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OidcSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OidcSingleV1SessionTermination: gu.Ptr(true), LoginV2: &feature_pb.LoginV2{ Required: true, BaseUri: gu.Ptr("https://login.com"), }, } want := &command.SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OIDCSingleV1SessionTermination: gu.Ptr(true), LoginV2: &feature.LoginV2{ Required: true, BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, @@ -60,14 +56,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: true, @@ -110,14 +98,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, - OidcLegacyIntrospection: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_SYSTEM, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, @@ -158,16 +138,13 @@ func Test_systemFeaturesToPb(t *testing.T) { func Test_instanceFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetInstanceFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - OidcLegacyIntrospection: nil, - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - WebKey: gu.Ptr(true), - DebugOidcParentError: gu.Ptr(true), - OidcSingleV1SessionTermination: gu.Ptr(true), - EnableBackChannelLogout: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + DebugOidcParentError: gu.Ptr(true), + OidcSingleV1SessionTermination: gu.Ptr(true), + EnableBackChannelLogout: gu.Ptr(true), LoginV2: &feature_pb.LoginV2{ Required: true, BaseUri: gu.Ptr("https://login.com"), @@ -175,16 +152,13 @@ func Test_instanceFeaturesToCommand(t *testing.T) { ConsoleUseV2UserApi: gu.Ptr(true), } want := &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - WebKey: gu.Ptr(true), - DebugOIDCParentError: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(true), - EnableBackChannelLogout: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + DebugOIDCParentError: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(true), + EnableBackChannelLogout: gu.Ptr(true), LoginV2: &feature.LoginV2{ Required: true, BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, @@ -207,14 +181,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -227,10 +193,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID}, }, - WebKey: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, OIDCSingleV1SessionTermination: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -265,14 +227,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, - OidcLegacyIntrospection: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, @@ -285,10 +239,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, Source: feature_pb.Source_SOURCE_SYSTEM, }, - WebKey: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, DebugOidcParentError: &feature_pb.FeatureFlag{ Enabled: false, Source: feature_pb.Source_SOURCE_UNSPECIFIED, diff --git a/internal/api/grpc/feature/v2/feature.go b/internal/api/grpc/feature/v2/feature.go index f4527689fc..f450f734e4 100644 --- a/internal/api/grpc/feature/v2/feature.go +++ b/internal/api/grpc/feature/v2/feature.go @@ -3,6 +3,7 @@ package feature import ( "context" + "connectrpc.com/connect" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -10,8 +11,8 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/feature/v2" ) -func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFeaturesRequest) (_ *feature.SetSystemFeaturesResponse, err error) { - features, err := systemFeaturesToCommand(req) +func (s *Server) SetSystemFeatures(ctx context.Context, req *connect.Request[feature.SetSystemFeaturesRequest]) (_ *connect.Response[feature.SetSystemFeaturesResponse], err error) { + features, err := systemFeaturesToCommand(req.Msg) if err != nil { return nil, err } @@ -19,31 +20,31 @@ func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFe if err != nil { return nil, err } - return &feature.SetSystemFeaturesResponse{ + return connect.NewResponse(&feature.SetSystemFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ResetSystemFeatures(ctx context.Context, req *feature.ResetSystemFeaturesRequest) (_ *feature.ResetSystemFeaturesResponse, err error) { +func (s *Server) ResetSystemFeatures(ctx context.Context, req *connect.Request[feature.ResetSystemFeaturesRequest]) (_ *connect.Response[feature.ResetSystemFeaturesResponse], err error) { details, err := s.command.ResetSystemFeatures(ctx) if err != nil { return nil, err } - return &feature.ResetSystemFeaturesResponse{ + return connect.NewResponse(&feature.ResetSystemFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) GetSystemFeatures(ctx context.Context, req *feature.GetSystemFeaturesRequest) (_ *feature.GetSystemFeaturesResponse, err error) { +func (s *Server) GetSystemFeatures(ctx context.Context, req *connect.Request[feature.GetSystemFeaturesRequest]) (_ *connect.Response[feature.GetSystemFeaturesResponse], err error) { f, err := s.query.GetSystemFeatures(ctx) if err != nil { return nil, err } - return systemFeaturesToPb(f), nil + return connect.NewResponse(systemFeaturesToPb(f)), nil } -func (s *Server) SetInstanceFeatures(ctx context.Context, req *feature.SetInstanceFeaturesRequest) (_ *feature.SetInstanceFeaturesResponse, err error) { - features, err := instanceFeaturesToCommand(req) +func (s *Server) SetInstanceFeatures(ctx context.Context, req *connect.Request[feature.SetInstanceFeaturesRequest]) (_ *connect.Response[feature.SetInstanceFeaturesResponse], err error) { + features, err := instanceFeaturesToCommand(req.Msg) if err != nil { return nil, err } @@ -51,44 +52,44 @@ func (s *Server) SetInstanceFeatures(ctx context.Context, req *feature.SetInstan if err != nil { return nil, err } - return &feature.SetInstanceFeaturesResponse{ + return connect.NewResponse(&feature.SetInstanceFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ResetInstanceFeatures(ctx context.Context, req *feature.ResetInstanceFeaturesRequest) (_ *feature.ResetInstanceFeaturesResponse, err error) { +func (s *Server) ResetInstanceFeatures(ctx context.Context, req *connect.Request[feature.ResetInstanceFeaturesRequest]) (_ *connect.Response[feature.ResetInstanceFeaturesResponse], err error) { details, err := s.command.ResetInstanceFeatures(ctx) if err != nil { return nil, err } - return &feature.ResetInstanceFeaturesResponse{ + return connect.NewResponse(&feature.ResetInstanceFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) GetInstanceFeatures(ctx context.Context, req *feature.GetInstanceFeaturesRequest) (_ *feature.GetInstanceFeaturesResponse, err error) { - f, err := s.query.GetInstanceFeatures(ctx, req.GetInheritance()) +func (s *Server) GetInstanceFeatures(ctx context.Context, req *connect.Request[feature.GetInstanceFeaturesRequest]) (_ *connect.Response[feature.GetInstanceFeaturesResponse], err error) { + f, err := s.query.GetInstanceFeatures(ctx, req.Msg.GetInheritance()) if err != nil { return nil, err } - return instanceFeaturesToPb(f), nil + return connect.NewResponse(instanceFeaturesToPb(f)), nil } -func (s *Server) SetOrganizationFeatures(ctx context.Context, req *feature.SetOrganizationFeaturesRequest) (_ *feature.SetOrganizationFeaturesResponse, err error) { +func (s *Server) SetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.SetOrganizationFeaturesRequest]) (_ *connect.Response[feature.SetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method SetOrganizationFeatures not implemented") } -func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *feature.ResetOrganizationFeaturesRequest) (_ *feature.ResetOrganizationFeaturesResponse, err error) { +func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.ResetOrganizationFeaturesRequest]) (_ *connect.Response[feature.ResetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method ResetOrganizationFeatures not implemented") } -func (s *Server) GetOrganizationFeatures(ctx context.Context, req *feature.GetOrganizationFeaturesRequest) (_ *feature.GetOrganizationFeaturesResponse, err error) { +func (s *Server) GetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.GetOrganizationFeaturesRequest]) (_ *connect.Response[feature.GetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method GetOrganizationFeatures not implemented") } -func (s *Server) SetUserFeatures(ctx context.Context, req *feature.SetUserFeatureRequest) (_ *feature.SetUserFeaturesResponse, err error) { +func (s *Server) SetUserFeatures(ctx context.Context, req *connect.Request[feature.SetUserFeatureRequest]) (_ *connect.Response[feature.SetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method SetUserFeatures not implemented") } -func (s *Server) ResetUserFeatures(ctx context.Context, req *feature.ResetUserFeaturesRequest) (_ *feature.ResetUserFeaturesResponse, err error) { +func (s *Server) ResetUserFeatures(ctx context.Context, req *connect.Request[feature.ResetUserFeaturesRequest]) (_ *connect.Response[feature.ResetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method ResetUserFeatures not implemented") } -func (s *Server) GetUserFeatures(ctx context.Context, req *feature.GetUserFeaturesRequest) (_ *feature.GetUserFeaturesResponse, err error) { +func (s *Server) GetUserFeatures(ctx context.Context, req *connect.Request[feature.GetUserFeaturesRequest]) (_ *connect.Response[feature.GetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method GetUserFeatures not implemented") } diff --git a/internal/api/grpc/feature/v2/integration_test/feature_test.go b/internal/api/grpc/feature/v2/integration_test/feature_test.go index f27b57ff8c..369f5b37b8 100644 --- a/internal/api/grpc/feature/v2/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2/integration_test/feature_test.go @@ -58,7 +58,7 @@ func TestServer_SetSystemFeatures(t *testing.T) { args: args{ ctx: IamCTX, req: &feature.SetSystemFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, wantErr: true, @@ -76,7 +76,7 @@ func TestServer_SetSystemFeatures(t *testing.T) { args: args{ ctx: SystemCTX, req: &feature.SetSystemFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, want: &feature.SetSystemFeaturesResponse{ @@ -170,8 +170,8 @@ func TestServer_GetSystemFeatures(t *testing.T) { name: "some features", prepare: func(t *testing.T) { _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(false), }) require.NoError(t, err) }, @@ -184,7 +184,7 @@ func TestServer_GetSystemFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_SYSTEM, }, @@ -208,8 +208,6 @@ func TestServer_GetSystemFeatures(t *testing.T) { } require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) - assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) - assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } @@ -231,7 +229,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: OrgCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, wantErr: true, @@ -249,7 +247,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: IamCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, want: &feature.SetInstanceFeaturesResponse{ @@ -321,7 +319,7 @@ func TestServer_ResetInstanceFeatures(t *testing.T) { func TestServer_GetInstanceFeatures(t *testing.T) { _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ - OidcLegacyIntrospection: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }) require.NoError(t, err) t.Cleanup(func() { @@ -358,14 +356,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { }, want: &feature.GetInstanceFeaturesResponse{ LoginDefaultOrg: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, - OidcLegacyIntrospection: &feature.FeatureFlag{ Enabled: true, Source: feature.Source_SOURCE_SYSTEM, }, @@ -379,9 +369,8 @@ func TestServer_GetInstanceFeatures(t *testing.T) { name: "some features, no inheritance", prepare: func(t *testing.T) { _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) require.NoError(t, err) }, @@ -394,10 +383,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_INSTANCE, - }, UserSchema: &feature.FeatureFlag{ Enabled: true, Source: feature.Source_SOURCE_INSTANCE, @@ -423,14 +408,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, - OidcLegacyIntrospection: &feature.FeatureFlag{ - Enabled: true, - Source: feature.Source_SOURCE_SYSTEM, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -455,8 +432,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { } require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) - assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) - assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } diff --git a/internal/api/grpc/feature/v2/server.go b/internal/api/grpc/feature/v2/server.go index ab92df5822..3eb4cc6813 100644 --- a/internal/api/grpc/feature/v2/server.go +++ b/internal/api/grpc/feature/v2/server.go @@ -1,17 +1,22 @@ package feature import ( - "google.golang.org/grpc" + "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/query" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2/featureconnect" ) +var _ featureconnect.FeatureServiceHandler = (*Server)(nil) + type Server struct { - feature.UnimplementedFeatureServiceServer command *command.Commands query *query.Queries } @@ -26,8 +31,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - feature.RegisterFeatureServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return featureconnect.NewFeatureServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return feature.File_zitadel_feature_v2_feature_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go index 9739e1c4c8..dc791d4c51 100644 --- a/internal/api/grpc/feature/v2beta/converter.go +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -10,55 +10,45 @@ import ( func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command.SystemFeatures { return &command.SystemFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, } } func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesResponse { return &feature_pb.GetSystemFeaturesResponse{ - Details: object.DomainToDetailsPb(f.Details), - LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), - OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), } } func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *command.InstanceFeatures { return &command.InstanceFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - WebKey: req.WebKey, - DebugOIDCParentError: req.DebugOidcParentError, - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + DebugOIDCParentError: req.DebugOidcParentError, + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, } } func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeaturesResponse { return &feature_pb.GetInstanceFeaturesResponse{ - Details: object.DomainToDetailsPb(f.Details), - LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), - OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - WebKey: featureSourceToFlagPb(&f.WebKey), - DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), } } diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go index 72d91b10d4..ec681011f0 100644 --- a/internal/api/grpc/feature/v2beta/converter_test.go +++ b/internal/api/grpc/feature/v2beta/converter_test.go @@ -18,22 +18,18 @@ import ( func Test_systemFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetSystemFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - OidcLegacyIntrospection: nil, - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OidcSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OidcSingleV1SessionTermination: gu.Ptr(true), } want := &command.SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OIDCSingleV1SessionTermination: gu.Ptr(true), } got := systemFeaturesToCommand(arg) assert.Equal(t, want, got) @@ -50,14 +46,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: true, @@ -85,14 +73,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, - OidcLegacyIntrospection: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_SYSTEM, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, @@ -116,24 +96,18 @@ func Test_systemFeaturesToPb(t *testing.T) { func Test_instanceFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetInstanceFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - OidcLegacyIntrospection: nil, - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - WebKey: gu.Ptr(true), - OidcSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OidcSingleV1SessionTermination: gu.Ptr(true), } want := &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - WebKey: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OIDCSingleV1SessionTermination: gu.Ptr(true), } got := instanceFeaturesToCommand(arg) assert.Equal(t, want, got) @@ -150,14 +124,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -170,10 +136,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID}, }, - WebKey: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, OIDCSingleV1SessionTermination: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -189,14 +151,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, - OidcLegacyIntrospection: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, @@ -209,10 +163,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, Source: feature_pb.Source_SOURCE_SYSTEM, }, - WebKey: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, DebugOidcParentError: &feature_pb.FeatureFlag{ Enabled: false, Source: feature_pb.Source_SOURCE_UNSPECIFIED, diff --git a/internal/api/grpc/feature/v2beta/feature.go b/internal/api/grpc/feature/v2beta/feature.go index b94f8e7de2..4ff51af883 100644 --- a/internal/api/grpc/feature/v2beta/feature.go +++ b/internal/api/grpc/feature/v2beta/feature.go @@ -3,6 +3,7 @@ package feature import ( "context" + "connectrpc.com/connect" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -10,77 +11,77 @@ import ( feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" ) -func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFeaturesRequest) (_ *feature.SetSystemFeaturesResponse, err error) { - details, err := s.command.SetSystemFeatures(ctx, systemFeaturesToCommand(req)) +func (s *Server) SetSystemFeatures(ctx context.Context, req *connect.Request[feature.SetSystemFeaturesRequest]) (_ *connect.Response[feature.SetSystemFeaturesResponse], err error) { + details, err := s.command.SetSystemFeatures(ctx, systemFeaturesToCommand(req.Msg)) if err != nil { return nil, err } - return &feature.SetSystemFeaturesResponse{ + return connect.NewResponse(&feature.SetSystemFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ResetSystemFeatures(ctx context.Context, req *feature.ResetSystemFeaturesRequest) (_ *feature.ResetSystemFeaturesResponse, err error) { +func (s *Server) ResetSystemFeatures(ctx context.Context, req *connect.Request[feature.ResetSystemFeaturesRequest]) (_ *connect.Response[feature.ResetSystemFeaturesResponse], err error) { details, err := s.command.ResetSystemFeatures(ctx) if err != nil { return nil, err } - return &feature.ResetSystemFeaturesResponse{ + return connect.NewResponse(&feature.ResetSystemFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) GetSystemFeatures(ctx context.Context, req *feature.GetSystemFeaturesRequest) (_ *feature.GetSystemFeaturesResponse, err error) { +func (s *Server) GetSystemFeatures(ctx context.Context, req *connect.Request[feature.GetSystemFeaturesRequest]) (_ *connect.Response[feature.GetSystemFeaturesResponse], err error) { f, err := s.query.GetSystemFeatures(ctx) if err != nil { return nil, err } - return systemFeaturesToPb(f), nil + return connect.NewResponse(systemFeaturesToPb(f)), nil } -func (s *Server) SetInstanceFeatures(ctx context.Context, req *feature.SetInstanceFeaturesRequest) (_ *feature.SetInstanceFeaturesResponse, err error) { - details, err := s.command.SetInstanceFeatures(ctx, instanceFeaturesToCommand(req)) +func (s *Server) SetInstanceFeatures(ctx context.Context, req *connect.Request[feature.SetInstanceFeaturesRequest]) (_ *connect.Response[feature.SetInstanceFeaturesResponse], err error) { + details, err := s.command.SetInstanceFeatures(ctx, instanceFeaturesToCommand(req.Msg)) if err != nil { return nil, err } - return &feature.SetInstanceFeaturesResponse{ + return connect.NewResponse(&feature.SetInstanceFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ResetInstanceFeatures(ctx context.Context, req *feature.ResetInstanceFeaturesRequest) (_ *feature.ResetInstanceFeaturesResponse, err error) { +func (s *Server) ResetInstanceFeatures(ctx context.Context, req *connect.Request[feature.ResetInstanceFeaturesRequest]) (_ *connect.Response[feature.ResetInstanceFeaturesResponse], err error) { details, err := s.command.ResetInstanceFeatures(ctx) if err != nil { return nil, err } - return &feature.ResetInstanceFeaturesResponse{ + return connect.NewResponse(&feature.ResetInstanceFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) GetInstanceFeatures(ctx context.Context, req *feature.GetInstanceFeaturesRequest) (_ *feature.GetInstanceFeaturesResponse, err error) { - f, err := s.query.GetInstanceFeatures(ctx, req.GetInheritance()) +func (s *Server) GetInstanceFeatures(ctx context.Context, req *connect.Request[feature.GetInstanceFeaturesRequest]) (_ *connect.Response[feature.GetInstanceFeaturesResponse], err error) { + f, err := s.query.GetInstanceFeatures(ctx, req.Msg.GetInheritance()) if err != nil { return nil, err } - return instanceFeaturesToPb(f), nil + return connect.NewResponse(instanceFeaturesToPb(f)), nil } -func (s *Server) SetOrganizationFeatures(ctx context.Context, req *feature.SetOrganizationFeaturesRequest) (_ *feature.SetOrganizationFeaturesResponse, err error) { +func (s *Server) SetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.SetOrganizationFeaturesRequest]) (_ *connect.Response[feature.SetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method SetOrganizationFeatures not implemented") } -func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *feature.ResetOrganizationFeaturesRequest) (_ *feature.ResetOrganizationFeaturesResponse, err error) { +func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.ResetOrganizationFeaturesRequest]) (_ *connect.Response[feature.ResetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method ResetOrganizationFeatures not implemented") } -func (s *Server) GetOrganizationFeatures(ctx context.Context, req *feature.GetOrganizationFeaturesRequest) (_ *feature.GetOrganizationFeaturesResponse, err error) { +func (s *Server) GetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.GetOrganizationFeaturesRequest]) (_ *connect.Response[feature.GetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method GetOrganizationFeatures not implemented") } -func (s *Server) SetUserFeatures(ctx context.Context, req *feature.SetUserFeatureRequest) (_ *feature.SetUserFeaturesResponse, err error) { +func (s *Server) SetUserFeatures(ctx context.Context, req *connect.Request[feature.SetUserFeatureRequest]) (_ *connect.Response[feature.SetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method SetUserFeatures not implemented") } -func (s *Server) ResetUserFeatures(ctx context.Context, req *feature.ResetUserFeaturesRequest) (_ *feature.ResetUserFeaturesResponse, err error) { +func (s *Server) ResetUserFeatures(ctx context.Context, req *connect.Request[feature.ResetUserFeaturesRequest]) (_ *connect.Response[feature.ResetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method ResetUserFeatures not implemented") } -func (s *Server) GetUserFeatures(ctx context.Context, req *feature.GetUserFeaturesRequest) (_ *feature.GetUserFeaturesResponse, err error) { +func (s *Server) GetUserFeatures(ctx context.Context, req *connect.Request[feature.GetUserFeaturesRequest]) (_ *connect.Response[feature.GetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method GetUserFeatures not implemented") } diff --git a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go index cbd9f5f939..4e24bb2a4f 100644 --- a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go @@ -61,7 +61,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: OrgCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, wantErr: true, @@ -79,7 +79,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: IamCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, want: &feature.SetInstanceFeaturesResponse{ @@ -190,14 +190,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, - OidcLegacyIntrospection: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -208,9 +200,8 @@ func TestServer_GetInstanceFeatures(t *testing.T) { name: "some features, no inheritance", prepare: func(t *testing.T) { _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) require.NoError(t, err) }, @@ -223,10 +214,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_INSTANCE, - }, UserSchema: &feature.FeatureFlag{ Enabled: true, Source: feature.Source_SOURCE_INSTANCE, @@ -252,14 +239,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, - OidcLegacyIntrospection: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -284,8 +263,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { } require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) - assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) - assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } diff --git a/internal/api/grpc/feature/v2beta/server.go b/internal/api/grpc/feature/v2beta/server.go index 4208c4acfc..29877f77f9 100644 --- a/internal/api/grpc/feature/v2beta/server.go +++ b/internal/api/grpc/feature/v2beta/server.go @@ -1,17 +1,22 @@ package feature import ( - "google.golang.org/grpc" + "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/query" feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta/featureconnect" ) +var _ featureconnect.FeatureServiceHandler = (*Server)(nil) + type Server struct { - feature.UnimplementedFeatureServiceServer command *command.Commands query *query.Queries } @@ -26,8 +31,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - feature.RegisterFeatureServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return featureconnect.NewFeatureServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return feature.File_zitadel_feature_v2beta_feature_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/filter/v2/converter.go b/internal/api/grpc/filter/v2/converter.go index 7a7d7cd8d7..98e8bdd4f8 100644 --- a/internal/api/grpc/filter/v2/converter.go +++ b/internal/api/grpc/filter/v2/converter.go @@ -9,6 +9,29 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/filter/v2" ) +func TextMethodPbToQuery(method filter.TextFilterMethod) query.TextComparison { + switch method { + case filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS: + return query.TextEquals + case filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS_IGNORE_CASE: + return query.TextEqualsIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH: + return query.TextStartsWith + case filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH_IGNORE_CASE: + return query.TextStartsWithIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS: + return query.TextContains + case filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE: + return query.TextContainsIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH: + return query.TextEndsWith + case filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH_IGNORE_CASE: + return query.TextEndsWithIgnoreCase + default: + return -1 + } +} + func TimestampMethodPbToQuery(method filter.TimestampFilterMethod) query.TimestampComparison { switch method { case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_EQUALS: diff --git a/internal/api/grpc/filter/v2beta/converter.go b/internal/api/grpc/filter/v2beta/converter.go index e34f9dd9d7..e15895e9d9 100644 --- a/internal/api/grpc/filter/v2beta/converter.go +++ b/internal/api/grpc/filter/v2beta/converter.go @@ -32,6 +32,23 @@ func TextMethodPbToQuery(method filter.TextFilterMethod) query.TextComparison { } } +func TimestampMethodPbToQuery(method filter.TimestampFilterMethod) query.TimestampComparison { + switch method { + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_EQUALS: + return query.TimestampEquals + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_LESS: + return query.TimestampLess + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_GREATER: + return query.TimestampGreater + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_LESS_OR_EQUALS: + return query.TimestampLessOrEquals + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_GREATER_OR_EQUALS: + return query.TimestampGreaterOrEquals + default: + return -1 + } +} + func PaginationPbToQuery(defaults systemdefaults.SystemDefaults, query *filter.PaginationRequest) (offset, limit uint64, asc bool, err error) { limit = defaults.DefaultQueryLimit if query == nil { diff --git a/internal/api/grpc/gerrors/zitadel_errors.go b/internal/api/grpc/gerrors/zitadel_errors.go index d679054da6..b5d2893062 100644 --- a/internal/api/grpc/gerrors/zitadel_errors.go +++ b/internal/api/grpc/gerrors/zitadel_errors.go @@ -3,10 +3,12 @@ package gerrors import ( "errors" + "connectrpc.com/connect" "github.com/jackc/pgx/v5/pgconn" "github.com/zitadel/logging" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/protoadapt" commandErrors "github.com/zitadel/zitadel/internal/command/errors" @@ -36,6 +38,30 @@ func ZITADELToGRPCError(err error) error { return s.Err() } +func ZITADELToConnectError(err error) error { + if err == nil { + return nil + } + connectError := new(connect.Error) + if errors.As(err, &connectError) { + return err + } + code, key, id, ok := ExtractZITADELError(err) + if !ok { + return status.Convert(err).Err() + } + msg := key + msg += " (" + id + ")" + + errorInfo := getErrorInfo(id, key, err) + + cErr := connect.NewError(connect.Code(code), errors.New(msg)) + if detail, detailErr := connect.NewErrorDetail(errorInfo.(proto.Message)); detailErr == nil { + cErr.AddDetail(detail) + } + return cErr +} + func ExtractZITADELError(err error) (c codes.Code, msg, id string, ok bool) { if err == nil { return codes.OK, "", "", false diff --git a/internal/api/grpc/idp/v2/query.go b/internal/api/grpc/idp/v2/query.go index 082a94d18f..587b1687b9 100644 --- a/internal/api/grpc/idp/v2/query.go +++ b/internal/api/grpc/idp/v2/query.go @@ -3,6 +3,7 @@ package idp import ( "context" + "connectrpc.com/connect" "github.com/crewjam/saml" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/durationpb" @@ -15,12 +16,12 @@ import ( idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" ) -func (s *Server) GetIDPByID(ctx context.Context, req *idp_pb.GetIDPByIDRequest) (*idp_pb.GetIDPByIDResponse, error) { - idp, err := s.query.IDPTemplateByID(ctx, true, req.Id, false, s.checkPermission) +func (s *Server) GetIDPByID(ctx context.Context, req *connect.Request[idp_pb.GetIDPByIDRequest]) (*connect.Response[idp_pb.GetIDPByIDResponse], error) { + idp, err := s.query.IDPTemplateByID(ctx, true, req.Msg.GetId(), false, s.checkPermission) if err != nil { return nil, err } - return &idp_pb.GetIDPByIDResponse{Idp: idpToPb(idp)}, nil + return connect.NewResponse(&idp_pb.GetIDPByIDResponse{Idp: idpToPb(idp)}), nil } func idpToPb(idp *query.IDPTemplate) *idp_pb.IDP { diff --git a/internal/api/grpc/idp/v2/server.go b/internal/api/grpc/idp/v2/server.go index 246e980434..666c39294d 100644 --- a/internal/api/grpc/idp/v2/server.go +++ b/internal/api/grpc/idp/v2/server.go @@ -1,7 +1,10 @@ package idp import ( - "google.golang.org/grpc" + "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" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/idp/v2" + "github.com/zitadel/zitadel/pkg/grpc/idp/v2/idpconnect" ) -var _ idp.IdentityProviderServiceServer = (*Server)(nil) +var _ idpconnect.IdentityProviderServiceHandler = (*Server)(nil) type Server struct { - idp.UnimplementedIdentityProviderServiceServer command *command.Commands query *query.Queries @@ -35,8 +38,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - idp.RegisterIdentityProviderServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return idpconnect.NewIdentityProviderServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return idp.File_zitadel_idp_v2_idp_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/instance/v2beta/domain.go b/internal/api/grpc/instance/v2beta/domain.go index 439c6e5d8d..380ebff5a7 100644 --- a/internal/api/grpc/instance/v2beta/domain.go +++ b/internal/api/grpc/instance/v2beta/domain.go @@ -3,48 +3,49 @@ package instance import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) -func (s *Server) AddCustomDomain(ctx context.Context, req *instance.AddCustomDomainRequest) (*instance.AddCustomDomainResponse, error) { - details, err := s.command.AddInstanceDomain(ctx, req.GetDomain()) +func (s *Server) AddCustomDomain(ctx context.Context, req *connect.Request[instance.AddCustomDomainRequest]) (*connect.Response[instance.AddCustomDomainResponse], error) { + details, err := s.command.AddInstanceDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.AddCustomDomainResponse{ + return connect.NewResponse(&instance.AddCustomDomainResponse{ CreationDate: timestamppb.New(details.CreationDate), - }, nil + }), nil } -func (s *Server) RemoveCustomDomain(ctx context.Context, req *instance.RemoveCustomDomainRequest) (*instance.RemoveCustomDomainResponse, error) { - details, err := s.command.RemoveInstanceDomain(ctx, req.GetDomain()) +func (s *Server) RemoveCustomDomain(ctx context.Context, req *connect.Request[instance.RemoveCustomDomainRequest]) (*connect.Response[instance.RemoveCustomDomainResponse], error) { + details, err := s.command.RemoveInstanceDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.RemoveCustomDomainResponse{ + return connect.NewResponse(&instance.RemoveCustomDomainResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) AddTrustedDomain(ctx context.Context, req *instance.AddTrustedDomainRequest) (*instance.AddTrustedDomainResponse, error) { - details, err := s.command.AddTrustedDomain(ctx, req.GetDomain()) +func (s *Server) AddTrustedDomain(ctx context.Context, req *connect.Request[instance.AddTrustedDomainRequest]) (*connect.Response[instance.AddTrustedDomainResponse], error) { + details, err := s.command.AddTrustedDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.AddTrustedDomainResponse{ + return connect.NewResponse(&instance.AddTrustedDomainResponse{ CreationDate: timestamppb.New(details.CreationDate), - }, nil + }), nil } -func (s *Server) RemoveTrustedDomain(ctx context.Context, req *instance.RemoveTrustedDomainRequest) (*instance.RemoveTrustedDomainResponse, error) { - details, err := s.command.RemoveTrustedDomain(ctx, req.GetDomain()) +func (s *Server) RemoveTrustedDomain(ctx context.Context, req *connect.Request[instance.RemoveTrustedDomainRequest]) (*connect.Response[instance.RemoveTrustedDomainResponse], error) { + details, err := s.command.RemoveTrustedDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.RemoveTrustedDomainResponse{ + return connect.NewResponse(&instance.RemoveTrustedDomainResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/instance/v2beta/instance.go b/internal/api/grpc/instance/v2beta/instance.go index b1c36e74bb..b3f2d6e478 100644 --- a/internal/api/grpc/instance/v2beta/instance.go +++ b/internal/api/grpc/instance/v2beta/instance.go @@ -3,30 +3,31 @@ package instance import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) -func (s *Server) DeleteInstance(ctx context.Context, request *instance.DeleteInstanceRequest) (*instance.DeleteInstanceResponse, error) { - obj, err := s.command.RemoveInstance(ctx, request.GetInstanceId()) +func (s *Server) DeleteInstance(ctx context.Context, request *connect.Request[instance.DeleteInstanceRequest]) (*connect.Response[instance.DeleteInstanceResponse], error) { + obj, err := s.command.RemoveInstance(ctx, request.Msg.GetInstanceId()) if err != nil { return nil, err } - return &instance.DeleteInstanceResponse{ + return connect.NewResponse(&instance.DeleteInstanceResponse{ DeletionDate: timestamppb.New(obj.EventDate), - }, nil + }), nil } -func (s *Server) UpdateInstance(ctx context.Context, request *instance.UpdateInstanceRequest) (*instance.UpdateInstanceResponse, error) { - obj, err := s.command.UpdateInstance(ctx, request.GetInstanceName()) +func (s *Server) UpdateInstance(ctx context.Context, request *connect.Request[instance.UpdateInstanceRequest]) (*connect.Response[instance.UpdateInstanceResponse], error) { + obj, err := s.command.UpdateInstance(ctx, request.Msg.GetInstanceName()) if err != nil { return nil, err } - return &instance.UpdateInstanceResponse{ + return connect.NewResponse(&instance.UpdateInstanceResponse{ ChangeDate: timestamppb.New(obj.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/instance/v2beta/query.go b/internal/api/grpc/instance/v2beta/query.go index 74f79313ea..10716ffda0 100644 --- a/internal/api/grpc/instance/v2beta/query.go +++ b/internal/api/grpc/instance/v2beta/query.go @@ -3,23 +3,25 @@ package instance import ( "context" + "connectrpc.com/connect" + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) -func (s *Server) GetInstance(ctx context.Context, _ *instance.GetInstanceRequest) (*instance.GetInstanceResponse, error) { +func (s *Server) GetInstance(ctx context.Context, _ *connect.Request[instance.GetInstanceRequest]) (*connect.Response[instance.GetInstanceResponse], error) { inst, err := s.query.Instance(ctx, true) if err != nil { return nil, err } - return &instance.GetInstanceResponse{ + return connect.NewResponse(&instance.GetInstanceResponse{ Instance: ToProtoObject(inst), - }, nil + }), nil } -func (s *Server) ListInstances(ctx context.Context, req *instance.ListInstancesRequest) (*instance.ListInstancesResponse, error) { - queries, err := ListInstancesRequestToModel(req, s.systemDefaults) +func (s *Server) ListInstances(ctx context.Context, req *connect.Request[instance.ListInstancesRequest]) (*connect.Response[instance.ListInstancesResponse], error) { + queries, err := ListInstancesRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err } @@ -29,14 +31,14 @@ func (s *Server) ListInstances(ctx context.Context, req *instance.ListInstancesR return nil, err } - return &instance.ListInstancesResponse{ + return connect.NewResponse(&instance.ListInstancesResponse{ Instances: InstancesToPb(instances.Instances), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, instances.SearchResponse), - }, nil + }), nil } -func (s *Server) ListCustomDomains(ctx context.Context, req *instance.ListCustomDomainsRequest) (*instance.ListCustomDomainsResponse, error) { - queries, err := ListCustomDomainsRequestToModel(req, s.systemDefaults) +func (s *Server) ListCustomDomains(ctx context.Context, req *connect.Request[instance.ListCustomDomainsRequest]) (*connect.Response[instance.ListCustomDomainsResponse], error) { + queries, err := ListCustomDomainsRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err } @@ -46,14 +48,14 @@ func (s *Server) ListCustomDomains(ctx context.Context, req *instance.ListCustom return nil, err } - return &instance.ListCustomDomainsResponse{ + return connect.NewResponse(&instance.ListCustomDomainsResponse{ Domains: DomainsToPb(domains.Domains), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse), - }, nil + }), nil } -func (s *Server) ListTrustedDomains(ctx context.Context, req *instance.ListTrustedDomainsRequest) (*instance.ListTrustedDomainsResponse, error) { - queries, err := ListTrustedDomainsRequestToModel(req, s.systemDefaults) +func (s *Server) ListTrustedDomains(ctx context.Context, req *connect.Request[instance.ListTrustedDomainsRequest]) (*connect.Response[instance.ListTrustedDomainsResponse], error) { + queries, err := ListTrustedDomainsRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err } @@ -63,8 +65,8 @@ func (s *Server) ListTrustedDomains(ctx context.Context, req *instance.ListTrust return nil, err } - return &instance.ListTrustedDomainsResponse{ + return connect.NewResponse(&instance.ListTrustedDomainsResponse{ TrustedDomain: trustedDomainsToPb(domains.Domains), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse), - }, nil + }), nil } diff --git a/internal/api/grpc/instance/v2beta/server.go b/internal/api/grpc/instance/v2beta/server.go index aaeaa4cc8f..1fb3513dd6 100644 --- a/internal/api/grpc/instance/v2beta/server.go +++ b/internal/api/grpc/instance/v2beta/server.go @@ -1,7 +1,10 @@ package instance import ( - "google.golang.org/grpc" + "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" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/query" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta/instanceconnect" ) -var _ instance.InstanceServiceServer = (*Server)(nil) +var _ instanceconnect.InstanceServiceHandler = (*Server)(nil) type Server struct { - instance.UnimplementedInstanceServiceServer command *command.Commands query *query.Queries systemDefaults systemdefaults.SystemDefaults @@ -39,8 +42,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - instance.RegisterInstanceServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return instanceconnect.NewInstanceServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return instance.File_zitadel_instance_v2beta_instance_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/internal_permission/v2beta/administrator.go b/internal/api/grpc/internal_permission/v2beta/administrator.go new file mode 100644 index 0000000000..86ee7d9454 --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/administrator.go @@ -0,0 +1,220 @@ +package internal_permission + +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/zerrors" + internal_permission "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" +) + +func (s *Server) CreateAdministrator(ctx context.Context, req *connect.Request[internal_permission.CreateAdministratorRequest]) (*connect.Response[internal_permission.CreateAdministratorResponse], error) { + var creationDate *timestamppb.Timestamp + + switch resource := req.Msg.GetResource().GetResource().(type) { + case *internal_permission.ResourceType_Instance: + if resource.Instance { + member, err := s.command.AddInstanceMember(ctx, createAdministratorInstanceToCommand(authz.GetInstance(ctx).InstanceID(), req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + creationDate = timestamppb.New(member.EventDate) + } + } + case *internal_permission.ResourceType_OrganizationId: + member, err := s.command.AddOrgMember(ctx, createAdministratorOrganizationToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + creationDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectId: + member, err := s.command.AddProjectMember(ctx, createAdministratorProjectToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + creationDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectGrant_: + member, err := s.command.AddProjectGrantMember(ctx, createAdministratorProjectGrantToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + creationDate = timestamppb.New(member.EventDate) + } + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ADMIN-IbPp47HDP5", "Errors.Invalid.Argument") + } + + return connect.NewResponse(&internal_permission.CreateAdministratorResponse{ + CreationDate: creationDate, + }), nil +} + +func createAdministratorInstanceToCommand(instanceID, userID string, roles []string) *command.AddInstanceMember { + return &command.AddInstanceMember{ + InstanceID: instanceID, + UserID: userID, + Roles: roles, + } +} + +func createAdministratorOrganizationToCommand(req *internal_permission.ResourceType_OrganizationId, userID string, roles []string) *command.AddOrgMember { + return &command.AddOrgMember{ + OrgID: req.OrganizationId, + UserID: userID, + Roles: roles, + } +} + +func createAdministratorProjectToCommand(req *internal_permission.ResourceType_ProjectId, userID string, roles []string) *command.AddProjectMember { + return &command.AddProjectMember{ + ProjectID: req.ProjectId, + UserID: userID, + Roles: roles, + } +} + +func createAdministratorProjectGrantToCommand(req *internal_permission.ResourceType_ProjectGrant_, userID string, roles []string) *command.AddProjectGrantMember { + return &command.AddProjectGrantMember{ + GrantID: req.ProjectGrant.ProjectGrantId, + ProjectID: req.ProjectGrant.ProjectId, + UserID: userID, + Roles: roles, + } +} + +func (s *Server) UpdateAdministrator(ctx context.Context, req *connect.Request[internal_permission.UpdateAdministratorRequest]) (*connect.Response[internal_permission.UpdateAdministratorResponse], error) { + var changeDate *timestamppb.Timestamp + + switch resource := req.Msg.GetResource().GetResource().(type) { + case *internal_permission.ResourceType_Instance: + if resource.Instance { + member, err := s.command.ChangeInstanceMember(ctx, updateAdministratorInstanceToCommand(authz.GetInstance(ctx).InstanceID(), req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + changeDate = timestamppb.New(member.EventDate) + } + } + case *internal_permission.ResourceType_OrganizationId: + member, err := s.command.ChangeOrgMember(ctx, updateAdministratorOrganizationToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + changeDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectId: + member, err := s.command.ChangeProjectMember(ctx, updateAdministratorProjectToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + changeDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectGrant_: + member, err := s.command.ChangeProjectGrantMember(ctx, updateAdministratorProjectGrantToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + changeDate = timestamppb.New(member.EventDate) + } + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ADMIN-i0V2IbdloZ", "Errors.Invalid.Argument") + } + + return connect.NewResponse(&internal_permission.UpdateAdministratorResponse{ + ChangeDate: changeDate, + }), nil +} + +func updateAdministratorInstanceToCommand(instanceID, userID string, roles []string) *command.ChangeInstanceMember { + return &command.ChangeInstanceMember{ + InstanceID: instanceID, + UserID: userID, + Roles: roles, + } +} + +func updateAdministratorOrganizationToCommand(req *internal_permission.ResourceType_OrganizationId, userID string, roles []string) *command.ChangeOrgMember { + return &command.ChangeOrgMember{ + OrgID: req.OrganizationId, + UserID: userID, + Roles: roles, + } +} + +func updateAdministratorProjectToCommand(req *internal_permission.ResourceType_ProjectId, userID string, roles []string) *command.ChangeProjectMember { + return &command.ChangeProjectMember{ + ProjectID: req.ProjectId, + UserID: userID, + Roles: roles, + } +} + +func updateAdministratorProjectGrantToCommand(req *internal_permission.ResourceType_ProjectGrant_, userID string, roles []string) *command.ChangeProjectGrantMember { + return &command.ChangeProjectGrantMember{ + GrantID: req.ProjectGrant.ProjectGrantId, + ProjectID: req.ProjectGrant.ProjectId, + UserID: userID, + Roles: roles, + } +} + +func (s *Server) DeleteAdministrator(ctx context.Context, req *connect.Request[internal_permission.DeleteAdministratorRequest]) (*connect.Response[internal_permission.DeleteAdministratorResponse], error) { + var deletionDate *timestamppb.Timestamp + + switch resource := req.Msg.GetResource().GetResource().(type) { + case *internal_permission.ResourceType_Instance: + if resource.Instance { + member, err := s.command.RemoveInstanceMember(ctx, authz.GetInstance(ctx).InstanceID(), req.Msg.UserId) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + deletionDate = timestamppb.New(member.EventDate) + } + } + case *internal_permission.ResourceType_OrganizationId: + member, err := s.command.RemoveOrgMember(ctx, resource.OrganizationId, req.Msg.UserId) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + deletionDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectId: + member, err := s.command.RemoveProjectMember(ctx, resource.ProjectId, req.Msg.UserId, "") + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + deletionDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectGrant_: + member, err := s.command.RemoveProjectGrantMember(ctx, resource.ProjectGrant.ProjectId, req.Msg.UserId, resource.ProjectGrant.ProjectGrantId) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + deletionDate = timestamppb.New(member.EventDate) + } + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ADMIN-3UOjLtuohh", "Errors.Invalid.Argument") + } + + return connect.NewResponse(&internal_permission.DeleteAdministratorResponse{ + DeletionDate: deletionDate, + }), nil +} diff --git a/internal/api/grpc/internal_permission/v2beta/integration/administrator_test.go b/internal/api/grpc/internal_permission/v2beta/integration/administrator_test.go new file mode 100644 index 0000000000..4d8e1c057c --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/integration/administrator_test.go @@ -0,0 +1,1848 @@ +//go:build integration + +package project_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/integration" + internal_permission "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" +) + +func TestServer_CreateAdministrator(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.CreateAdministratorRequest) + req *internal_permission.CreateAdministratorRequest + want + wantErr bool + }{ + { + name: "empty user ID", + ctx: iamOwnerCtx, + req: &internal_permission.CreateAdministratorRequest{ + UserId: "", + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "empty roles", + ctx: iamOwnerCtx, + req: &internal_permission.CreateAdministratorRequest{ + UserId: "notexisting", + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{}, + }, + wantErr: true, + }, + { + name: "empty resource", + ctx: iamOwnerCtx, + req: &internal_permission.CreateAdministratorRequest{ + UserId: "notexisting", + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "already existing, error", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "instance, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "instance, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "org, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: "notexisting", + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + wantErr: true, + }, + { + name: "org, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, no existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "project, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: "notexisting", + }, + }, + Roles: []string{"PROJECT_OWNER"}, + }, + wantErr: true, + }, + { + name: "project, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + }, + req: &internal_permission.CreateAdministratorRequest{ + Roles: []string{"PROJECT_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + }, + req: &internal_permission.CreateAdministratorRequest{ + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "project grant, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: "notexisting", + }, + }, + } + }, + req: &internal_permission.CreateAdministratorRequest{ + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + wantErr: true, + }, + { + name: "project grant, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + }, + req: &internal_permission.CreateAdministratorRequest{ + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project grant, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + }, + req: &internal_permission.CreateAdministratorRequest{ + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + creationDate := time.Now().UTC() + got, err := instance.Client.InternalPermissionv2Beta.CreateAdministrator(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertCreateAdministratorResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func TestServer_CreateAdministrator_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + userProjectResp := instance.CreateMachineUser(iamOwnerCtx) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userProjectResp.GetUserId()) + patProjectResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectResp.Token) + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + userProjectGrantResp := instance.CreateMachineUser(iamOwnerCtx) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) + patProjectGrantResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.CreateAdministratorRequest) + req *internal_permission.CreateAdministratorRequest + want + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "instance, missing permission, org owner", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "instance, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, org owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + wantErr: true, + }, + { + name: "project, project owner, ok", + ctx: projectOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + Roles: []string{"PROJECT_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + Roles: []string{"PROJECT_OWNER"}, + }, + wantErr: true, + }, + { + name: "project grant, project grant owner, ok", + ctx: projectGrantOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project grant, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + creationDate := time.Now().UTC() + got, err := instance.Client.InternalPermissionv2Beta.CreateAdministrator(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertCreateAdministratorResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func assertCreateAdministratorResponse(t *testing.T, creationDate, changeDate time.Time, expectedCreationDate bool, actualResp *internal_permission.CreateAdministratorResponse) { + 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) + } +} + +func TestServer_UpdateAdministrator(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.UpdateAdministratorRequest) + req *internal_permission.UpdateAdministratorRequest + want + wantErr bool + }{ + { + name: "empty user ID", + ctx: iamOwnerCtx, + req: &internal_permission.UpdateAdministratorRequest{ + UserId: "", + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "empty roles", + ctx: iamOwnerCtx, + req: &internal_permission.UpdateAdministratorRequest{ + UserId: "notexisting", + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{}, + }, + wantErr: true, + }, + { + name: "empty resource", + ctx: iamOwnerCtx, + req: &internal_permission.UpdateAdministratorRequest{ + UserId: "notexisting", + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "instance, no change", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "instance, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER_VIEWER"}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "instance, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "org, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: "notexisting", + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + wantErr: true, + }, + { + name: "org, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER_VIEWER"}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "org, no change", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "org, no existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "project, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: "notexisting", + }, + }, + Roles: []string{"PROJECT_OWNER"}, + }, + wantErr: true, + }, + { + name: "project, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"PROJECT_OWNER_VIEWER"}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "project, no change", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"PROJECT_OWNER"}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "project, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "project grant, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: "notexisting", + }, + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"PROJECT_GRANT_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "project grant, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"PROJECT_GRANT_OWNER_VIEWER"}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "project grant, no change", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "project grant, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + if tt.prepare != nil { + tt.prepare(tt.req) + } + got, err := instance.Client.InternalPermissionv2Beta.UpdateAdministrator(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertUpdateAdministratorResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func TestServer_UpdateAdministrator_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + userProjectResp := instance.CreateMachineUser(iamOwnerCtx) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userProjectResp.GetUserId()) + patProjectResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectResp.Token) + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + userProjectGrantResp := instance.CreateMachineUser(iamOwnerCtx) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) + patProjectGrantResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.UpdateAdministratorRequest) + req *internal_permission.UpdateAdministratorRequest + want + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "instance, missing permission, org owner", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "instance, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER_VIEWER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER_VIEWER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, org owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER_VIEWER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "project, project owner, ok", + ctx: projectOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + Roles: []string{"PROJECT_OWNER_VIEWER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + Roles: []string{"PROJECT_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "project grant, project grant owner, ok", + ctx: projectGrantOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER_VIEWER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project grant, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "project grant, project owner, error", + ctx: projectOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER_VIEWER"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + creationDate := time.Now().UTC() + got, err := instance.Client.InternalPermissionv2Beta.UpdateAdministrator(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertUpdateAdministratorResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func assertUpdateAdministratorResponse(t *testing.T, creationDate, changeDate time.Time, expectedChangeDate bool, actualResp *internal_permission.UpdateAdministratorResponse) { + 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) + } +} + +func TestServer_DeleteAdministrator(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) + req *internal_permission.DeleteAdministratorRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "empty user ID", + ctx: iamOwnerCtx, + req: &internal_permission.DeleteAdministratorRequest{ + UserId: "", + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantErr: true, + }, + { + name: "instance, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantDeletionDate: false, + }, + { + name: "instance, already removed", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + instance.DeleteInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantDeletionDate: true, + }, + { + name: "instance, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + return creationDate, time.Time{} + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantDeletionDate: true, + }, + { + name: "org, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: "notexisting", + }, + }, + }, + wantDeletionDate: false, + }, + { + name: "org, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + return creationDate, time.Time{} + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + }, + wantDeletionDate: true, + }, + { + name: "org, already removed", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + instance.DeleteOrgMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + }, + wantDeletionDate: true, + }, + { + name: "project, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: "notexisting", + }, + }, + }, + wantDeletionDate: false, + }, + { + name: "project, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + return creationDate, time.Time{} + }, + req: &internal_permission.DeleteAdministratorRequest{}, + wantDeletionDate: true, + }, + { + name: "project, already removed", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + instance.DeleteProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{}, + wantDeletionDate: true, + }, + { + name: "project grant, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: "notexisting", + }, + }, + } + return creationDate, time.Time{} + }, + req: &internal_permission.DeleteAdministratorRequest{}, + wantDeletionDate: false, + }, + { + name: "project grant, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + return creationDate, time.Time{} + }, + req: &internal_permission.DeleteAdministratorRequest{}, + wantDeletionDate: true, + }, + { + name: "project grant, already removed", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + instance.DeleteProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{}, + 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.InternalPermissionv2Beta.DeleteAdministrator(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteAdministratorResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func TestServer_DeleteAdministrator_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + userProjectResp := instance.CreateMachineUser(iamOwnerCtx) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userProjectResp.GetUserId()) + patProjectResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectResp.Token) + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + userProjectGrantResp := instance.CreateMachineUser(iamOwnerCtx) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) + patProjectGrantResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.DeleteAdministratorRequest) + req *internal_permission.DeleteAdministratorRequest + want + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantErr: true, + }, + { + name: "missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantErr: true, + }, + { + name: "instance, missing permission, org owner", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantErr: true, + }, + { + name: "instance, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, org owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + }, + wantErr: true, + }, + { + name: "project, project owner, ok", + ctx: projectOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + }, + wantErr: true, + }, + { + name: "project grant, project grant owner, ok", + ctx: projectGrantOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project grant, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + }, + wantErr: true, + }, + { + name: "project grant, project owner, error", + ctx: projectOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + creationDate := time.Now().UTC() + got, err := instance.Client.InternalPermissionv2Beta.DeleteAdministrator(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteAdministratorResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func assertDeleteAdministratorResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *internal_permission.DeleteAdministratorResponse) { + if expectedDeletionDate { + if !deletionDate.IsZero() { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, deletionDate) + } else { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.DeletionDate) + } +} diff --git a/internal/api/grpc/internal_permission/v2beta/integration/query_test.go b/internal/api/grpc/internal_permission/v2beta/integration/query_test.go new file mode 100644 index 0000000000..63b07c5194 --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/integration/query_test.go @@ -0,0 +1,1173 @@ +//go:build integration + +package project_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + internal_permission "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" +) + +func TestServer_ListAdministrators(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + projectName := gofakeit.AppName() + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), projectName, false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + userProjectResp := instance.CreateMachineUser(iamOwnerCtx) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userProjectResp.GetUserId()) + patProjectResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectResp.Token) + + userProjectGrantResp := instance.CreateMachineUser(iamOwnerCtx) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) + patProjectGrantResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) + + type args struct { + ctx context.Context + dep func(*internal_permission.ListAdministratorsRequest, *internal_permission.ListAdministratorsResponse) + req *internal_permission.ListAdministratorsRequest + } + tests := []struct { + name string + args args + want *internal_permission.ListAdministratorsResponse + wantErr bool + }{ + { + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{}, + }, + }, + { + name: "list by id, missing permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{Ids: []string{"notexisting"}}, + }, + }, + }, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list single id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin2 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin3 := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, org", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createOrganizationAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, org", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin3 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin2 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin3 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + admin2 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + admin3 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, instance owner", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + response.Administrators[2] = admin2 + response.Administrators[3] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, org owner", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + response.Administrators[2] = admin2 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, project owner", + args: args{ + ctx: projectOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, + }, + }, + }, + { + name: "list multiple id, project grant owner", + args: args{ + ctx: projectGrantOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instance.Client.InternalPermissionv2Beta.ListAdministrators(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Administrators, len(tt.want.Administrators)) { + for i := range tt.want.Administrators { + assert.EqualExportedValues(ttt, tt.want.Administrators[i], got.Administrators[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +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 createInstanceAdministrator(ctx context.Context, instance *integration.Instance, t *testing.T) *internal_permission.Administrator { + email := gofakeit.Email() + userResp := instance.CreateUserTypeHuman(ctx, email) + memberResp := instance.CreateInstanceMembership(t, ctx, userResp.GetId()) + return &internal_permission.Administrator{ + CreationDate: memberResp.GetCreationDate(), + ChangeDate: memberResp.GetCreationDate(), + User: &internal_permission.User{ + Id: userResp.GetId(), + PreferredLoginName: email, + DisplayName: "Mickey Mouse", + OrganizationId: instance.DefaultOrg.GetId(), + }, + Resource: &internal_permission.Administrator_Instance{ + Instance: true, + }, + Roles: []string{"IAM_OWNER"}, + } +} + +func createOrganizationAdministrator(ctx context.Context, instance *integration.Instance, t *testing.T) *internal_permission.Administrator { + email := gofakeit.Email() + userResp := instance.CreateUserTypeHuman(ctx, email) + memberResp := instance.CreateOrgMembership(t, ctx, instance.DefaultOrg.Id, userResp.GetId()) + return &internal_permission.Administrator{ + CreationDate: memberResp.GetCreationDate(), + ChangeDate: memberResp.GetCreationDate(), + User: &internal_permission.User{ + Id: userResp.GetId(), + PreferredLoginName: email, + DisplayName: "Mickey Mouse", + OrganizationId: instance.DefaultOrg.GetId(), + }, + Resource: &internal_permission.Administrator_Organization{ + Organization: &internal_permission.Organization{ + Id: instance.DefaultOrg.GetId(), + Name: instance.DefaultOrg.GetName(), + }, + }, + Roles: []string{"ORG_OWNER"}, + } +} + +func createProjectAdministrator(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, projectID, projectName string) *internal_permission.Administrator { + email := gofakeit.Email() + userResp := instance.CreateUserTypeHuman(ctx, email) + memberResp := instance.CreateProjectMembership(t, ctx, projectID, userResp.GetId()) + return &internal_permission.Administrator{ + CreationDate: memberResp.GetCreationDate(), + ChangeDate: memberResp.GetCreationDate(), + User: &internal_permission.User{ + Id: userResp.GetId(), + PreferredLoginName: email, + DisplayName: "Mickey Mouse", + OrganizationId: instance.DefaultOrg.GetId(), + }, + Resource: &internal_permission.Administrator_Project{ + Project: &internal_permission.Project{ + Id: projectID, + Name: projectName, + OrganizationId: orgID, + }, + }, + Roles: []string{"PROJECT_OWNER"}, + } +} + +func createProjectGrantAdministrator(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, projectID, projectName, grantedOrgID string) *internal_permission.Administrator { + email := gofakeit.Email() + userResp := instance.CreateUserTypeHuman(ctx, email) + memberResp := instance.CreateProjectGrantMembership(t, ctx, projectID, grantedOrgID, userResp.GetId()) + return &internal_permission.Administrator{ + CreationDate: memberResp.GetCreationDate(), + ChangeDate: memberResp.GetCreationDate(), + User: &internal_permission.User{ + Id: userResp.GetId(), + PreferredLoginName: email, + DisplayName: "Mickey Mouse", + OrganizationId: instance.DefaultOrg.GetId(), + }, + Resource: &internal_permission.Administrator_ProjectGrant{ + ProjectGrant: &internal_permission.ProjectGrant{ + Id: grantedOrgID, + ProjectId: projectID, + ProjectName: projectName, + OrganizationId: orgID, + GrantedOrganizationId: grantedOrgID, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER"}, + } +} + +func TestServer_ListAdministrators_PermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + projectName := gofakeit.AppName() + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, instancePermissionV2.DefaultOrg.GetId(), projectName, false, false) + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instancePermissionV2.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + userProjectResp := instancePermissionV2.CreateMachineUser(iamOwnerCtx) + instancePermissionV2.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userProjectResp.GetUserId()) + patProjectResp := instancePermissionV2.CreatePersonalAccessToken(iamOwnerCtx, userProjectResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectResp.Token) + + userProjectGrantResp := instancePermissionV2.CreateMachineUser(iamOwnerCtx) + instancePermissionV2.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) + patProjectGrantResp := instancePermissionV2.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) + + type args struct { + ctx context.Context + dep func(*internal_permission.ListAdministratorsRequest, *internal_permission.ListAdministratorsResponse) + req *internal_permission.ListAdministratorsRequest + } + tests := []struct { + name string + args args + want *internal_permission.ListAdministratorsResponse + wantErr bool + }{ + { + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{}, + }, + }, + { + name: "list by id, missing permission", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{Ids: []string{"notexisting"}}, + }, + }, + }, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list single id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, org", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, org", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin2 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin3 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + admin2 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + admin3 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, instance owner", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + response.Administrators[2] = admin2 + response.Administrators[3] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, org owner", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + response.Administrators[2] = admin2 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, project owner", + args: args{ + ctx: projectOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, + }, + }, + }, + // TODO: correct when permission check is added for project grants https://github.com/zitadel/zitadel/issues/9972 + { + name: "list multiple id, project grant owner", + args: args{ + ctx: projectGrantOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + // response.Administrators[0] = admin4 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instancePermissionV2.Client.InternalPermissionv2Beta.ListAdministrators(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Administrators, len(tt.want.Administrators)) { + for i := range tt.want.Administrators { + assert.EqualExportedValues(ttt, tt.want.Administrators[i], got.Administrators[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} diff --git a/internal/api/grpc/internal_permission/v2beta/integration/server_test.go b/internal/api/grpc/internal_permission/v2beta/integration/server_test.go new file mode 100644 index 0000000000..59d9745222 --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/integration/server_test.go @@ -0,0 +1,63 @@ +//go:build integration + +package project_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" +) + +var ( + CTX context.Context + instance *integration.Instance + instancePermissionV2 *integration.Instance +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + CTX = ctx + instance = integration.NewInstance(ctx) + instancePermissionV2 = integration.NewInstance(CTX) + return m.Run() + }()) +} + +func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(t, err) + if f.PermissionCheckV2.GetEnabled() { + return + } + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: gu.Ptr(true), + }) + require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, + func(ttt *assert.CollectT) { + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + assert.NoError(ttt, err) + if f.PermissionCheckV2.GetEnabled() { + return + } + }, + retryDuration, + tick, + "timed out waiting for ensuring instance feature") +} diff --git a/internal/api/grpc/internal_permission/v2beta/query.go b/internal/api/grpc/internal_permission/v2beta/query.go new file mode 100644 index 0000000000..3a8de83292 --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/query.go @@ -0,0 +1,192 @@ +package internal_permission + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + internal_permission "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" +) + +func (s *Server) ListAdministrators(ctx context.Context, req *connect.Request[internal_permission.ListAdministratorsRequest]) (*connect.Response[internal_permission.ListAdministratorsResponse], error) { + queries, err := s.listAdministratorsRequestToModel(req.Msg) + if err != nil { + return nil, err + } + resp, err := s.query.SearchAdministrators(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + return connect.NewResponse(&internal_permission.ListAdministratorsResponse{ + Administrators: administratorsToPb(resp.Administrators), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), + }), nil +} + +func (s *Server) listAdministratorsRequestToModel(req *internal_permission.ListAdministratorsRequest) (*query.MembershipSearchQuery, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := administratorSearchFiltersToQuery(req.Filters) + if err != nil { + return nil, err + } + return &query.MembershipSearchQuery{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: administratorFieldNameToSortingColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func administratorFieldNameToSortingColumn(field internal_permission.AdministratorFieldName) query.Column { + switch field { + case internal_permission.AdministratorFieldName_ADMINISTRATOR_FIELD_NAME_CREATION_DATE: + return query.MembershipCreationDate + case internal_permission.AdministratorFieldName_ADMINISTRATOR_FIELD_NAME_USER_ID: + return query.MembershipUserID + case internal_permission.AdministratorFieldName_ADMINISTRATOR_FIELD_NAME_CHANGE_DATE: + return query.MembershipChangeDate + case internal_permission.AdministratorFieldName_ADMINISTRATOR_FIELD_NAME_UNSPECIFIED: + return query.MembershipCreationDate + default: + return query.MembershipCreationDate + } +} + +func administratorSearchFiltersToQuery(queries []*internal_permission.AdministratorSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, qry := range queries { + q[i], err = administratorFilterToModel(qry) + if err != nil { + return nil, err + } + } + return q, nil +} + +func administratorFilterToModel(filter *internal_permission.AdministratorSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *internal_permission.AdministratorSearchFilter_InUserIdsFilter: + return inUserIDsFilterToQuery(q.InUserIdsFilter) + case *internal_permission.AdministratorSearchFilter_CreationDate: + return creationDateFilterToQuery(q.CreationDate) + case *internal_permission.AdministratorSearchFilter_ChangeDate: + return changeDateFilterToQuery(q.ChangeDate) + case *internal_permission.AdministratorSearchFilter_UserOrganizationId: + return userResourceOwnerFilterToQuery(q.UserOrganizationId) + case *internal_permission.AdministratorSearchFilter_UserPreferredLoginName: + return userLoginNameFilterToQuery(q.UserPreferredLoginName) + case *internal_permission.AdministratorSearchFilter_UserDisplayName: + return userDisplayNameFilterToQuery(q.UserDisplayName) + case *internal_permission.AdministratorSearchFilter_Resource: + return resourceFilterToQuery(q.Resource) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ORG-vR9nC", "List.Query.Invalid") + } +} + +func inUserIDsFilterToQuery(q *filter_pb.InIDsFilter) (query.SearchQuery, error) { + return query.NewMemberInUserIDsSearchQuery(q.GetIds()) +} + +func userResourceOwnerFilterToQuery(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewAdministratorUserResourceOwnerSearchQuery(q.GetId()) +} + +func userLoginNameFilterToQuery(q *internal_permission.UserPreferredLoginNameFilter) (query.SearchQuery, error) { + return query.NewAdministratorUserLoginNameSearchQuery(q.GetPreferredLoginName()) +} + +func userDisplayNameFilterToQuery(q *internal_permission.UserDisplayNameFilter) (query.SearchQuery, error) { + return query.NewAdministratorUserDisplayNameSearchQuery(q.GetDisplayName()) +} + +func creationDateFilterToQuery(q *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewMembershipCreationDateQuery(q.GetTimestamp().AsTime(), filter.TimestampMethodPbToQuery(q.Method)) +} + +func changeDateFilterToQuery(q *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewMembershipChangeDateQuery(q.GetTimestamp().AsTime(), filter.TimestampMethodPbToQuery(q.Method)) +} + +func resourceFilterToQuery(q *internal_permission.ResourceFilter) (query.SearchQuery, error) { + switch q.GetResource().(type) { + case *internal_permission.ResourceFilter_Instance: + if q.GetInstance() { + return query.NewMembershipIsIAMQuery() + } + case *internal_permission.ResourceFilter_OrganizationId: + return query.NewMembershipOrgIDQuery(q.GetOrganizationId()) + case *internal_permission.ResourceFilter_ProjectId: + return query.NewMembershipProjectIDQuery(q.GetProjectId()) + case *internal_permission.ResourceFilter_ProjectGrantId: + return query.NewMembershipProjectGrantIDQuery(q.GetProjectGrantId()) + } + return nil, nil +} + +func administratorsToPb(administrators []*query.Administrator) []*internal_permission.Administrator { + a := make([]*internal_permission.Administrator, len(administrators)) + for i, admin := range administrators { + a[i] = administratorToPb(admin) + } + return a +} + +func administratorToPb(admin *query.Administrator) *internal_permission.Administrator { + var resource internal_permission.Resource + if admin.Instance != nil { + resource = &internal_permission.Administrator_Instance{Instance: true} + } + if admin.Org != nil { + resource = &internal_permission.Administrator_Organization{ + Organization: &internal_permission.Organization{ + Id: admin.Org.OrgID, + Name: admin.Org.Name, + }, + } + } + if admin.Project != nil { + resource = &internal_permission.Administrator_Project{ + Project: &internal_permission.Project{ + Id: admin.Project.ProjectID, + Name: admin.Project.Name, + OrganizationId: admin.Project.ResourceOwner, + }, + } + } + if admin.ProjectGrant != nil { + resource = &internal_permission.Administrator_ProjectGrant{ + ProjectGrant: &internal_permission.ProjectGrant{ + Id: admin.ProjectGrant.GrantID, + ProjectId: admin.ProjectGrant.ProjectID, + ProjectName: admin.ProjectGrant.ProjectName, + OrganizationId: admin.ProjectGrant.ResourceOwner, + GrantedOrganizationId: admin.ProjectGrant.GrantedOrgID, + }, + } + } + + return &internal_permission.Administrator{ + CreationDate: timestamppb.New(admin.CreationDate), + ChangeDate: timestamppb.New(admin.ChangeDate), + User: &internal_permission.User{ + Id: admin.User.UserID, + PreferredLoginName: admin.User.LoginName, + DisplayName: admin.User.DisplayName, + OrganizationId: admin.User.ResourceOwner, + }, + Resource: resource, + Roles: admin.Roles, + } +} diff --git a/internal/api/grpc/internal_permission/v2beta/server.go b/internal/api/grpc/internal_permission/v2beta/server.go new file mode 100644 index 0000000000..bc1a999faa --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/server.go @@ -0,0 +1,66 @@ +package internal_permission + +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/domain" + "github.com/zitadel/zitadel/internal/query" + internal_permission "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta/internal_permissionconnect" +) + +var _ internal_permissionconnect.InternalPermissionServiceHandler = (*Server)(nil) + +type Server struct { + systemDefaults systemdefaults.SystemDefaults + command *command.Commands + query *query.Queries + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + systemDefaults systemdefaults.SystemDefaults, + command *command.Commands, + query *query.Queries, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + systemDefaults: systemDefaults, + command: command, + query: query, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return internal_permissionconnect.NewInternalPermissionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return internal_permission.File_zitadel_internal_permission_v2beta_internal_permission_service_proto +} + +func (s *Server) AppName() string { + return internal_permission.InternalPermissionService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return internal_permission.InternalPermissionService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return internal_permission.InternalPermissionService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return internal_permission.RegisterInternalPermissionServiceHandler +} diff --git a/internal/api/grpc/management/org.go b/internal/api/grpc/management/org.go index 70f509a4d7..ee4f5eb633 100644 --- a/internal/api/grpc/management/org.go +++ b/internal/api/grpc/management/org.go @@ -278,28 +278,28 @@ func (s *Server) ListOrgMembers(ctx context.Context, req *mgmt_pb.ListOrgMembers } func (s *Server) AddOrgMember(ctx context.Context, req *mgmt_pb.AddOrgMemberRequest) (*mgmt_pb.AddOrgMemberResponse, error) { - addedMember, err := s.command.AddOrgMember(ctx, authz.GetCtxData(ctx).OrgID, req.UserId, req.Roles...) + addedMember, err := s.command.AddOrgMember(ctx, AddOrgMemberRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.AddOrgMemberResponse{ Details: object.AddToDetailsPb( addedMember.Sequence, - addedMember.ChangeDate, + addedMember.EventDate, addedMember.ResourceOwner, ), }, nil } func (s *Server) UpdateOrgMember(ctx context.Context, req *mgmt_pb.UpdateOrgMemberRequest) (*mgmt_pb.UpdateOrgMemberResponse, error) { - changedMember, err := s.command.ChangeOrgMember(ctx, UpdateOrgMemberRequestToDomain(ctx, req)) + changedMember, err := s.command.ChangeOrgMember(ctx, UpdateOrgMemberRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.UpdateOrgMemberResponse{ Details: object.ChangeToDetailsPb( changedMember.Sequence, - changedMember.ChangeDate, + changedMember.EventDate, changedMember.ResourceOwner, ), }, nil diff --git a/internal/api/grpc/management/org_converter.go b/internal/api/grpc/management/org_converter.go index 03de84cdf4..07c772189c 100644 --- a/internal/api/grpc/management/org_converter.go +++ b/internal/api/grpc/management/org_converter.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/metadata" "github.com/zitadel/zitadel/internal/api/grpc/object" org_grpc "github.com/zitadel/zitadel/internal/api/grpc/org" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" @@ -67,8 +68,20 @@ func SetPrimaryOrgDomainRequestToDomain(ctx context.Context, req *mgmt_pb.SetPri } } -func UpdateOrgMemberRequestToDomain(ctx context.Context, req *mgmt_pb.UpdateOrgMemberRequest) *domain.Member { - return domain.NewMember(authz.GetCtxData(ctx).OrgID, req.UserId, req.Roles...) +func AddOrgMemberRequestToCommand(req *mgmt_pb.AddOrgMemberRequest, orgID string) *command.AddOrgMember { + return &command.AddOrgMember{ + OrgID: orgID, + UserID: req.UserId, + Roles: req.Roles, + } +} + +func UpdateOrgMemberRequestToCommand(req *mgmt_pb.UpdateOrgMemberRequest, orgID string) *command.ChangeOrgMember { + return &command.ChangeOrgMember{ + OrgID: orgID, + UserID: req.UserId, + Roles: req.Roles, + } } func ListOrgMembersRequestToModel(ctx context.Context, req *mgmt_pb.ListOrgMembersRequest) (*query.OrgMembersQuery, error) { diff --git a/internal/api/grpc/management/project.go b/internal/api/grpc/management/project.go index f3af8dbf86..be196d14ce 100644 --- a/internal/api/grpc/management/project.go +++ b/internal/api/grpc/management/project.go @@ -227,7 +227,7 @@ func (s *Server) RemoveProject(ctx context.Context, req *mgmt_pb.RemoveProjectRe } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery}, - }, true) + }, true, nil) if err != nil { return nil, err } @@ -312,7 +312,7 @@ func (s *Server) RemoveProjectRole(ctx context.Context, req *mgmt_pb.RemoveProje } userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery, rolesQuery}, - }, false) + }, false, nil) if err != nil { return nil, err @@ -354,24 +354,28 @@ func (s *Server) ListProjectMembers(ctx context.Context, req *mgmt_pb.ListProjec } func (s *Server) AddProjectMember(ctx context.Context, req *mgmt_pb.AddProjectMemberRequest) (*mgmt_pb.AddProjectMemberResponse, error) { - member, err := s.command.AddProjectMember(ctx, AddProjectMemberRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + member, err := s.command.AddProjectMember(ctx, AddProjectMemberRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.AddProjectMemberResponse{ - Details: object_grpc.AddToDetailsPb(member.Sequence, member.ChangeDate, member.ResourceOwner), + Details: object_grpc.AddToDetailsPb( + member.Sequence, + member.EventDate, + member.ResourceOwner, + ), }, nil } func (s *Server) UpdateProjectMember(ctx context.Context, req *mgmt_pb.UpdateProjectMemberRequest) (*mgmt_pb.UpdateProjectMemberResponse, error) { - member, err := s.command.ChangeProjectMember(ctx, UpdateProjectMemberRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + member, err := s.command.ChangeProjectMember(ctx, UpdateProjectMemberRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.UpdateProjectMemberResponse{ Details: object_grpc.ChangeToDetailsPb( member.Sequence, - member.ChangeDate, + member.EventDate, member.ResourceOwner, ), }, nil diff --git a/internal/api/grpc/management/project_application.go b/internal/api/grpc/management/project_application.go index ab49905409..a5526d3cb7 100644 --- a/internal/api/grpc/management/project_application.go +++ b/internal/api/grpc/management/project_application.go @@ -29,7 +29,7 @@ func (s *Server) ListApps(ctx context.Context, req *mgmt_pb.ListAppsRequest) (*m if err != nil { return nil, err } - apps, err := s.query.SearchApps(ctx, queries, false) + apps, err := s.query.SearchApps(ctx, queries, nil) if err != nil { return nil, err } @@ -125,7 +125,7 @@ func (s *Server) AddAPIApp(ctx context.Context, req *mgmt_pb.AddAPIAppRequest) ( } func (s *Server) UpdateApp(ctx context.Context, req *mgmt_pb.UpdateAppRequest) (*mgmt_pb.UpdateAppResponse, error) { - details, err := s.command.ChangeApplication(ctx, req.ProjectId, UpdateAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + details, err := s.command.UpdateApplicationName(ctx, req.ProjectId, UpdateAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -139,7 +139,7 @@ func (s *Server) UpdateOIDCAppConfig(ctx context.Context, req *mgmt_pb.UpdateOID if err != nil { return nil, err } - config, err := s.command.ChangeOIDCApplication(ctx, oidcApp, authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateOIDCApplication(ctx, oidcApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -157,7 +157,7 @@ func (s *Server) UpdateSAMLAppConfig(ctx context.Context, req *mgmt_pb.UpdateSAM if err != nil { return nil, err } - config, err := s.command.ChangeSAMLApplication(ctx, samlApp, authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateSAMLApplication(ctx, samlApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -171,7 +171,7 @@ func (s *Server) UpdateSAMLAppConfig(ctx context.Context, req *mgmt_pb.UpdateSAM } func (s *Server) UpdateAPIAppConfig(ctx context.Context, req *mgmt_pb.UpdateAPIAppConfigRequest) (*mgmt_pb.UpdateAPIAppConfigResponse, error) { - config, err := s.command.ChangeAPIApplication(ctx, UpdateAPIAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateAPIApplication(ctx, UpdateAPIAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/project_application_converter.go b/internal/api/grpc/management/project_application_converter.go index 13a0048a5b..fa31565445 100644 --- a/internal/api/grpc/management/project_application_converter.go +++ b/internal/api/grpc/management/project_application_converter.go @@ -4,6 +4,8 @@ import ( "context" "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/api/authz" authn_grpc "github.com/zitadel/zitadel/internal/api/grpc/authn" "github.com/zitadel/zitadel/internal/api/grpc/object" @@ -46,24 +48,24 @@ func AddOIDCAppRequestToDomain(req *mgmt_pb.AddOIDCAppRequest) (*domain.OIDCApp, AggregateID: req.ProjectId, }, AppName: req.Name, - OIDCVersion: app_grpc.OIDCVersionToDomain(req.Version), + OIDCVersion: gu.Ptr(app_grpc.OIDCVersionToDomain(req.Version)), RedirectUris: req.RedirectUris, ResponseTypes: app_grpc.OIDCResponseTypesToDomain(req.ResponseTypes), GrantTypes: app_grpc.OIDCGrantTypesToDomain(req.GrantTypes), - ApplicationType: app_grpc.OIDCApplicationTypeToDomain(req.AppType), - AuthMethodType: app_grpc.OIDCAuthMethodTypeToDomain(req.AuthMethodType), + ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(req.AppType)), + AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(req.AuthMethodType)), PostLogoutRedirectUris: req.PostLogoutRedirectUris, - DevMode: req.DevMode, - AccessTokenType: app_grpc.OIDCTokenTypeToDomain(req.AccessTokenType), - AccessTokenRoleAssertion: req.AccessTokenRoleAssertion, - IDTokenRoleAssertion: req.IdTokenRoleAssertion, - IDTokenUserinfoAssertion: req.IdTokenUserinfoAssertion, - ClockSkew: req.ClockSkew.AsDuration(), + DevMode: gu.Ptr(req.GetDevMode()), + AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(req.AccessTokenType)), + AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), AdditionalOrigins: req.AdditionalOrigins, - SkipNativeAppSuccessPage: req.SkipNativeAppSuccessPage, - BackChannelLogoutURI: req.GetBackChannelLogoutUri(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -78,9 +80,9 @@ func AddSAMLAppRequestToDomain(req *mgmt_pb.AddSAMLAppRequest) (*domain.SAMLApp, }, AppName: req.Name, Metadata: req.GetMetadataXml(), - MetadataURL: req.GetMetadataUrl(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + MetadataURL: gu.Ptr(req.GetMetadataUrl()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -114,20 +116,20 @@ func UpdateOIDCAppConfigRequestToDomain(app *mgmt_pb.UpdateOIDCAppConfigRequest) RedirectUris: app.RedirectUris, ResponseTypes: app_grpc.OIDCResponseTypesToDomain(app.ResponseTypes), GrantTypes: app_grpc.OIDCGrantTypesToDomain(app.GrantTypes), - ApplicationType: app_grpc.OIDCApplicationTypeToDomain(app.AppType), - AuthMethodType: app_grpc.OIDCAuthMethodTypeToDomain(app.AuthMethodType), + ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(app.AppType)), + AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(app.AuthMethodType)), PostLogoutRedirectUris: app.PostLogoutRedirectUris, - DevMode: app.DevMode, - AccessTokenType: app_grpc.OIDCTokenTypeToDomain(app.AccessTokenType), - AccessTokenRoleAssertion: app.AccessTokenRoleAssertion, - IDTokenRoleAssertion: app.IdTokenRoleAssertion, - IDTokenUserinfoAssertion: app.IdTokenUserinfoAssertion, - ClockSkew: app.ClockSkew.AsDuration(), + DevMode: gu.Ptr(app.GetDevMode()), + AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(app.AccessTokenType)), + AccessTokenRoleAssertion: gu.Ptr(app.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(app.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(app.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), AdditionalOrigins: app.AdditionalOrigins, - SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, - BackChannelLogoutURI: app.BackChannelLogoutUri, - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(app.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(app.GetBackChannelLogoutUri()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -142,9 +144,9 @@ func UpdateSAMLAppConfigRequestToDomain(app *mgmt_pb.UpdateSAMLAppConfigRequest) }, AppID: app.AppId, Metadata: app.GetMetadataXml(), - MetadataURL: app.GetMetadataUrl(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + MetadataURL: gu.Ptr(app.GetMetadataUrl()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -175,7 +177,7 @@ func AddAPIClientKeyRequestToDomain(key *mgmt_pb.AddAppKeyRequest) *domain.Appli } func ListAPIClientKeysRequestToQuery(ctx context.Context, req *mgmt_pb.ListAppKeysRequest) (*query.AuthNKeySearchQueries, error) { - resourcOwner, err := query.NewAuthNKeyResourceOwnerQuery(authz.GetCtxData(ctx).OrgID) + resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -195,7 +197,7 @@ func ListAPIClientKeysRequestToQuery(ctx context.Context, req *mgmt_pb.ListAppKe Asc: asc, }, Queries: []query.SearchQuery{ - resourcOwner, + resourceOwner, projectID, appID, }, diff --git a/internal/api/grpc/management/project_converter.go b/internal/api/grpc/management/project_converter.go index 83a8246feb..8ea0014800 100644 --- a/internal/api/grpc/management/project_converter.go +++ b/internal/api/grpc/management/project_converter.go @@ -104,12 +104,22 @@ func ProjectGrantsToIDs(projectGrants *query.ProjectGrants) []string { return converted } -func AddProjectMemberRequestToDomain(req *mgmt_pb.AddProjectMemberRequest) *domain.Member { - return domain.NewMember(req.ProjectId, req.UserId, req.Roles...) +func AddProjectMemberRequestToCommand(req *mgmt_pb.AddProjectMemberRequest, orgID string) *command.AddProjectMember { + return &command.AddProjectMember{ + ResourceOwner: orgID, + ProjectID: req.ProjectId, + UserID: req.UserId, + Roles: req.Roles, + } } -func UpdateProjectMemberRequestToDomain(req *mgmt_pb.UpdateProjectMemberRequest) *domain.Member { - return domain.NewMember(req.ProjectId, req.UserId, req.Roles...) +func UpdateProjectMemberRequestToCommand(req *mgmt_pb.UpdateProjectMemberRequest, orgID string) *command.ChangeProjectMember { + return &command.ChangeProjectMember{ + ResourceOwner: orgID, + ProjectID: req.ProjectId, + UserID: req.UserId, + Roles: req.Roles, + } } func listProjectRequestToModel(req *mgmt_pb.ListProjectsRequest) (*query.ProjectSearchQueries, error) { diff --git a/internal/api/grpc/management/project_grant.go b/internal/api/grpc/management/project_grant.go index d84375818d..26f51f6851 100644 --- a/internal/api/grpc/management/project_grant.go +++ b/internal/api/grpc/management/project_grant.go @@ -91,7 +91,7 @@ func (s *Server) UpdateProjectGrant(ctx context.Context, req *mgmt_pb.UpdateProj } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery, grantQuery}, - }, true) + }, true, nil) if err != nil { return nil, err } @@ -139,7 +139,7 @@ func (s *Server) RemoveProjectGrant(ctx context.Context, req *mgmt_pb.RemoveProj } userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery, grantQuery}, - }, false) + }, false, nil) if err != nil { return nil, err } @@ -176,28 +176,28 @@ func (s *Server) ListProjectGrantMembers(ctx context.Context, req *mgmt_pb.ListP } func (s *Server) AddProjectGrantMember(ctx context.Context, req *mgmt_pb.AddProjectGrantMemberRequest) (*mgmt_pb.AddProjectGrantMemberResponse, error) { - member, err := s.command.AddProjectGrantMember(ctx, AddProjectGrantMemberRequestToDomain(req)) + member, err := s.command.AddProjectGrantMember(ctx, AddProjectGrantMemberRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.AddProjectGrantMemberResponse{ Details: object_grpc.AddToDetailsPb( member.Sequence, - member.ChangeDate, + member.EventDate, member.ResourceOwner, ), }, nil } func (s *Server) UpdateProjectGrantMember(ctx context.Context, req *mgmt_pb.UpdateProjectGrantMemberRequest) (*mgmt_pb.UpdateProjectGrantMemberResponse, error) { - member, err := s.command.ChangeProjectGrantMember(ctx, UpdateProjectGrantMemberRequestToDomain(req)) + member, err := s.command.ChangeProjectGrantMember(ctx, UpdateProjectGrantMemberRequestToCommand(req)) if err != nil { return nil, err } return &mgmt_pb.UpdateProjectGrantMemberResponse{ Details: object_grpc.ChangeToDetailsPb( member.Sequence, - member.ChangeDate, + member.EventDate, member.ResourceOwner, ), }, nil diff --git a/internal/api/grpc/management/project_grant_converter.go b/internal/api/grpc/management/project_grant_converter.go index 04bc35301f..0523eed13a 100644 --- a/internal/api/grpc/management/project_grant_converter.go +++ b/internal/api/grpc/management/project_grant_converter.go @@ -7,7 +7,6 @@ import ( member_grpc "github.com/zitadel/zitadel/internal/api/grpc/member" "github.com/zitadel/zitadel/internal/api/grpc/object" "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" @@ -146,24 +145,21 @@ func ListProjectGrantMembersRequestToModel(ctx context.Context, req *mgmt_pb.Lis }, nil } -func AddProjectGrantMemberRequestToDomain(req *mgmt_pb.AddProjectGrantMemberRequest) *domain.ProjectGrantMember { - return &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: req.ProjectId, - }, - GrantID: req.GrantId, - UserID: req.UserId, - Roles: req.Roles, +func AddProjectGrantMemberRequestToCommand(req *mgmt_pb.AddProjectGrantMemberRequest, orgID string) *command.AddProjectGrantMember { + return &command.AddProjectGrantMember{ + ResourceOwner: orgID, + ProjectID: req.ProjectId, + GrantID: req.GrantId, + UserID: req.UserId, + Roles: req.Roles, } } -func UpdateProjectGrantMemberRequestToDomain(req *mgmt_pb.UpdateProjectGrantMemberRequest) *domain.ProjectGrantMember { - return &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: req.ProjectId, - }, - GrantID: req.GrantId, - UserID: req.UserId, - Roles: req.Roles, +func UpdateProjectGrantMemberRequestToCommand(req *mgmt_pb.UpdateProjectGrantMemberRequest) *command.ChangeProjectGrantMember { + return &command.ChangeProjectGrantMember{ + ProjectID: req.ProjectId, + GrantID: req.GrantId, + UserID: req.UserId, + Roles: req.Roles, } } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 09b9faa756..f40a29868e 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -142,7 +142,7 @@ func (s *Server) ListUserMetadata(ctx context.Context, req *mgmt_pb.ListUserMeta if err != nil { return nil, err } - res, err := s.query.SearchUserMetadata(ctx, true, req.Id, metadataQueries, false) + res, err := s.query.SearchUserMetadata(ctx, true, req.Id, metadataQueries, nil) if err != nil { return nil, err } @@ -369,7 +369,7 @@ func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]* } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, - }, true) + }, true, nil) if err != nil { return nil, nil, err } diff --git a/internal/api/grpc/management/user_grant.go b/internal/api/grpc/management/user_grant.go index 8589b49e30..3a894c8c29 100644 --- a/internal/api/grpc/management/user_grant.go +++ b/internal/api/grpc/management/user_grant.go @@ -33,7 +33,7 @@ func (s *Server) ListUserGrants(ctx context.Context, req *mgmt_pb.ListUserGrantR if err != nil { return nil, err } - res, err := s.query.UserGrants(ctx, queries, false) + res, err := s.query.UserGrants(ctx, queries, false, nil) if err != nil { return nil, err } @@ -44,11 +44,11 @@ func (s *Server) ListUserGrants(ctx context.Context, req *mgmt_pb.ListUserGrantR } func (s *Server) AddUserGrant(ctx context.Context, req *mgmt_pb.AddUserGrantRequest) (*mgmt_pb.AddUserGrantResponse, error) { - grant := AddUserGrantRequestToDomain(req) + grant := AddUserGrantRequestToDomain(req, authz.GetCtxData(ctx).OrgID) if err := checkExplicitProjectPermission(ctx, grant.ProjectGrantID, grant.ProjectID); err != nil { return nil, err } - grant, err := s.command.AddUserGrant(ctx, grant, authz.GetCtxData(ctx).OrgID) + grant, err := s.command.AddUserGrant(ctx, grant, nil) if err != nil { return nil, err } @@ -63,7 +63,7 @@ func (s *Server) AddUserGrant(ctx context.Context, req *mgmt_pb.AddUserGrantRequ } func (s *Server) UpdateUserGrant(ctx context.Context, req *mgmt_pb.UpdateUserGrantRequest) (*mgmt_pb.UpdateUserGrantResponse, error) { - grant, err := s.command.ChangeUserGrant(ctx, UpdateUserGrantRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + grant, err := s.command.ChangeUserGrant(ctx, UpdateUserGrantRequestToDomain(req, authz.GetCtxData(ctx).OrgID), false, false, nil) if err != nil { return nil, err } @@ -77,7 +77,7 @@ func (s *Server) UpdateUserGrant(ctx context.Context, req *mgmt_pb.UpdateUserGra } func (s *Server) DeactivateUserGrant(ctx context.Context, req *mgmt_pb.DeactivateUserGrantRequest) (*mgmt_pb.DeactivateUserGrantResponse, error) { - objectDetails, err := s.command.DeactivateUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID) + objectDetails, err := s.command.DeactivateUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID, nil) if err != nil { return nil, err } @@ -87,7 +87,7 @@ func (s *Server) DeactivateUserGrant(ctx context.Context, req *mgmt_pb.Deactivat } func (s *Server) ReactivateUserGrant(ctx context.Context, req *mgmt_pb.ReactivateUserGrantRequest) (*mgmt_pb.ReactivateUserGrantResponse, error) { - objectDetails, err := s.command.ReactivateUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID) + objectDetails, err := s.command.ReactivateUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID, nil) if err != nil { return nil, err } @@ -97,7 +97,7 @@ func (s *Server) ReactivateUserGrant(ctx context.Context, req *mgmt_pb.Reactivat } func (s *Server) RemoveUserGrant(ctx context.Context, req *mgmt_pb.RemoveUserGrantRequest) (*mgmt_pb.RemoveUserGrantResponse, error) { - objectDetails, err := s.command.RemoveUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID) + objectDetails, err := s.command.RemoveUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID, false, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user_grant_converter.go b/internal/api/grpc/management/user_grant_converter.go index 17d992d251..ad1b4acdf6 100644 --- a/internal/api/grpc/management/user_grant_converter.go +++ b/internal/api/grpc/management/user_grant_converter.go @@ -49,19 +49,23 @@ func shouldAppendUserGrantOwnerQuery(queries []*user.UserGrantQuery) bool { return true } -func AddUserGrantRequestToDomain(req *mgmt_pb.AddUserGrantRequest) *domain.UserGrant { +func AddUserGrantRequestToDomain(req *mgmt_pb.AddUserGrantRequest, resourceowner string) *domain.UserGrant { return &domain.UserGrant{ UserID: req.UserId, ProjectID: req.ProjectId, ProjectGrantID: req.ProjectGrantId, RoleKeys: req.RoleKeys, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: resourceowner, + }, } } -func UpdateUserGrantRequestToDomain(req *mgmt_pb.UpdateUserGrantRequest) *domain.UserGrant { +func UpdateUserGrantRequestToDomain(req *mgmt_pb.UpdateUserGrantRequest, resourceowner string) *domain.UserGrant { return &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.GrantId, + AggregateID: req.GrantId, + ResourceOwner: resourceowner, }, UserID: req.UserId, RoleKeys: req.RoleKeys, diff --git a/internal/api/grpc/metadata/v2/metadata.go b/internal/api/grpc/metadata/v2/metadata.go new file mode 100644 index 0000000000..f50ad57f64 --- /dev/null +++ b/internal/api/grpc/metadata/v2/metadata.go @@ -0,0 +1,47 @@ +package metadata + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + filter_v2 "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + meta_pb "github.com/zitadel/zitadel/pkg/grpc/metadata/v2" +) + +func UserMetadataListToPb(dataList []*query.UserMetadata) []*meta_pb.Metadata { + mds := make([]*meta_pb.Metadata, len(dataList)) + for i, data := range dataList { + mds[i] = UserMetadataToPb(data) + } + return mds +} + +func UserMetadataToPb(data *query.UserMetadata) *meta_pb.Metadata { + return &meta_pb.Metadata{ + Key: data.Key, + Value: data.Value, + CreationDate: timestamppb.New(data.CreationDate), + ChangeDate: timestamppb.New(data.ChangeDate), + } +} + +func UserMetadataFiltersToQuery(queries []*meta_pb.MetadataSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = UserMetadataFilterToQuery(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func UserMetadataFilterToQuery(filter *meta_pb.MetadataSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *meta_pb.MetadataSearchFilter_KeyFilter: + return query.NewUserMetadataKeySearchQuery(q.KeyFilter.Key, filter_v2.TextMethodPbToQuery(q.KeyFilter.Method)) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "METAD-fdg23", "List.Query.Invalid") + } +} diff --git a/internal/api/grpc/oidc/v2/oidc.go b/internal/api/grpc/oidc/v2/oidc.go index 8612d11558..d56d6da056 100644 --- a/internal/api/grpc/oidc/v2/oidc.go +++ b/internal/api/grpc/oidc/v2/oidc.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" + "connectrpc.com/connect" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" "google.golang.org/protobuf/types/known/durationpb" @@ -18,30 +19,30 @@ import ( oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) -func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequestRequest) (*oidc_pb.GetAuthRequestResponse, error) { - authRequest, err := s.query.AuthRequestByID(ctx, true, req.GetAuthRequestId(), true) +func (s *Server) GetAuthRequest(ctx context.Context, req *connect.Request[oidc_pb.GetAuthRequestRequest]) (*connect.Response[oidc_pb.GetAuthRequestResponse], error) { + authRequest, err := s.query.AuthRequestByID(ctx, true, req.Msg.GetAuthRequestId(), true) if err != nil { logging.WithError(err).Error("query authRequest by ID") return nil, err } - return &oidc_pb.GetAuthRequestResponse{ + return connect.NewResponse(&oidc_pb.GetAuthRequestResponse{ AuthRequest: authRequestToPb(authRequest), - }, nil + }), nil } -func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { - switch v := req.GetCallbackKind().(type) { +func (s *Server) CreateCallback(ctx context.Context, req *connect.Request[oidc_pb.CreateCallbackRequest]) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { + switch v := req.Msg.GetCallbackKind().(type) { case *oidc_pb.CreateCallbackRequest_Error: - return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error) + return s.failAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Error) case *oidc_pb.CreateCallbackRequest_Session: - return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session) + return s.linkSessionToAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Session) default: return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v) } } -func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb.GetDeviceAuthorizationRequestRequest) (*oidc_pb.GetDeviceAuthorizationRequestResponse, error) { - deviceRequest, err := s.query.DeviceAuthRequestByUserCode(ctx, req.GetUserCode()) +func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *connect.Request[oidc_pb.GetDeviceAuthorizationRequestRequest]) (*connect.Response[oidc_pb.GetDeviceAuthorizationRequestResponse], error) { + deviceRequest, err := s.query.DeviceAuthRequestByUserCode(ctx, req.Msg.GetUserCode()) if err != nil { return nil, err } @@ -49,7 +50,7 @@ func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb if err != nil { return nil, err } - return &oidc_pb.GetDeviceAuthorizationRequestResponse{ + return connect.NewResponse(&oidc_pb.GetDeviceAuthorizationRequestResponse{ DeviceAuthorizationRequest: &oidc_pb.DeviceAuthorizationRequest{ Id: base64.RawURLEncoding.EncodeToString(encrypted), ClientId: deviceRequest.ClientID, @@ -57,24 +58,24 @@ func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb AppName: deviceRequest.AppName, ProjectName: deviceRequest.ProjectName, }, - }, nil + }), nil } -func (s *Server) AuthorizeOrDenyDeviceAuthorization(ctx context.Context, req *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest) (*oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse, error) { - deviceCode, err := s.deviceCodeFromID(req.GetDeviceAuthorizationId()) +func (s *Server) AuthorizeOrDenyDeviceAuthorization(ctx context.Context, req *connect.Request[oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest]) (*connect.Response[oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse], error) { + deviceCode, err := s.deviceCodeFromID(req.Msg.GetDeviceAuthorizationId()) if err != nil { return nil, err } - switch req.GetDecision().(type) { + switch req.Msg.GetDecision().(type) { case *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session: - _, err = s.command.ApproveDeviceAuthWithSession(ctx, deviceCode, req.GetSession().GetSessionId(), req.GetSession().GetSessionToken()) + _, err = s.command.ApproveDeviceAuthWithSession(ctx, deviceCode, req.Msg.GetSession().GetSessionId(), req.Msg.GetSession().GetSessionToken()) case *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Deny: _, err = s.command.CancelDeviceAuth(ctx, deviceCode, domain.DeviceAuthCanceledDenied) } if err != nil { return nil, err } - return &oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse{}, nil + return connect.NewResponse(&oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse{}), nil } func authRequestToPb(a *query.AuthRequest) *oidc_pb.AuthRequest { @@ -136,7 +137,7 @@ func (s *Server) checkPermission(ctx context.Context, clientID string, userID st return nil } -func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.FailAuthRequest(ctx, authRequestID, errorReasonToDomain(ae.GetError())) if err != nil { return nil, err @@ -146,13 +147,13 @@ func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae * if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } -func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err @@ -172,10 +173,10 @@ func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID str if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } func errorReasonToDomain(errorReason oidc_pb.ErrorReason) domain.OIDCErrorReason { diff --git a/internal/api/grpc/oidc/v2/server.go b/internal/api/grpc/oidc/v2/server.go index 99234ee3d7..3d8f78a8ad 100644 --- a/internal/api/grpc/oidc/v2/server.go +++ b/internal/api/grpc/oidc/v2/server.go @@ -1,7 +1,10 @@ package oidc import ( - "google.golang.org/grpc" + "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" @@ -10,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/query" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" + "github.com/zitadel/zitadel/pkg/grpc/oidc/v2/oidcconnect" ) -var _ oidc_pb.OIDCServiceServer = (*Server)(nil) +var _ oidcconnect.OIDCServiceHandler = (*Server)(nil) type Server struct { - oidc_pb.UnimplementedOIDCServiceServer command *command.Commands query *query.Queries @@ -42,8 +45,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - oidc_pb.RegisterOIDCServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return oidcconnect.NewOIDCServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return oidc_pb.File_zitadel_oidc_v2_oidc_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/oidc/v2beta/oidc.go b/internal/api/grpc/oidc/v2beta/oidc.go index 66c4bee828..432e6f833f 100644 --- a/internal/api/grpc/oidc/v2beta/oidc.go +++ b/internal/api/grpc/oidc/v2beta/oidc.go @@ -3,6 +3,7 @@ package oidc import ( "context" + "connectrpc.com/connect" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" "google.golang.org/protobuf/types/known/durationpb" @@ -17,15 +18,15 @@ import ( oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" ) -func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequestRequest) (*oidc_pb.GetAuthRequestResponse, error) { - authRequest, err := s.query.AuthRequestByID(ctx, true, req.GetAuthRequestId(), true) +func (s *Server) GetAuthRequest(ctx context.Context, req *connect.Request[oidc_pb.GetAuthRequestRequest]) (*connect.Response[oidc_pb.GetAuthRequestResponse], error) { + authRequest, err := s.query.AuthRequestByID(ctx, true, req.Msg.GetAuthRequestId(), true) if err != nil { logging.WithError(err).Error("query authRequest by ID") return nil, err } - return &oidc_pb.GetAuthRequestResponse{ + return connect.NewResponse(&oidc_pb.GetAuthRequestResponse{ AuthRequest: authRequestToPb(authRequest), - }, nil + }), nil } func authRequestToPb(a *query.AuthRequest) *oidc_pb.AuthRequest { @@ -73,18 +74,18 @@ func promptToPb(p domain.Prompt) oidc_pb.Prompt { } } -func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { - switch v := req.GetCallbackKind().(type) { +func (s *Server) CreateCallback(ctx context.Context, req *connect.Request[oidc_pb.CreateCallbackRequest]) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { + switch v := req.Msg.GetCallbackKind().(type) { case *oidc_pb.CreateCallbackRequest_Error: - return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error) + return s.failAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Error) case *oidc_pb.CreateCallbackRequest_Session: - return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session) + return s.linkSessionToAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Session) default: return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v) } } -func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.FailAuthRequest(ctx, authRequestID, errorReasonToDomain(ae.GetError())) if err != nil { return nil, err @@ -94,10 +95,10 @@ func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae * if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } func (s *Server) checkPermission(ctx context.Context, clientID string, userID string) error { @@ -114,7 +115,7 @@ func (s *Server) checkPermission(ctx context.Context, clientID string, userID st return nil } -func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err @@ -130,10 +131,10 @@ func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID str if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } func errorReasonToDomain(errorReason oidc_pb.ErrorReason) domain.OIDCErrorReason { diff --git a/internal/api/grpc/oidc/v2beta/server.go b/internal/api/grpc/oidc/v2beta/server.go index 7595ae927e..5309a5093e 100644 --- a/internal/api/grpc/oidc/v2beta/server.go +++ b/internal/api/grpc/oidc/v2beta/server.go @@ -1,7 +1,10 @@ package oidc import ( - "google.golang.org/grpc" + "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" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta/oidcconnect" ) -var _ oidc_pb.OIDCServiceServer = (*Server)(nil) +var _ oidcconnect.OIDCServiceHandler = (*Server)(nil) type Server struct { - oidc_pb.UnimplementedOIDCServiceServer command *command.Commands query *query.Queries @@ -38,8 +41,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - oidc_pb.RegisterOIDCServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return oidcconnect.NewOIDCServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return oidc_pb.File_zitadel_oidc_v2beta_oidc_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/org/v2/org.go b/internal/api/grpc/org/v2/org.go index b876826365..42832d147f 100644 --- a/internal/api/grpc/org/v2/org.go +++ b/internal/api/grpc/org/v2/org.go @@ -3,6 +3,8 @@ package org import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/api/grpc/user/v2" "github.com/zitadel/zitadel/internal/command" @@ -10,8 +12,8 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/org/v2" ) -func (s *Server) AddOrganization(ctx context.Context, request *org.AddOrganizationRequest) (*org.AddOrganizationResponse, error) { - orgSetup, err := addOrganizationRequestToCommand(request) +func (s *Server) AddOrganization(ctx context.Context, request *connect.Request[org.AddOrganizationRequest]) (*connect.Response[org.AddOrganizationResponse], error) { + orgSetup, err := addOrganizationRequestToCommand(request.Msg) if err != nil { return nil, err } @@ -68,7 +70,7 @@ func addOrganizationRequestAdminToCommand(admin *org.AddOrganizationRequest_Admi } } -func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganizationResponse, err error) { +func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *connect.Response[org.AddOrganizationResponse], err error) { admins := make([]*org.AddOrganizationResponse_CreatedAdmin, 0, len(createdOrg.OrgAdmins)) for _, admin := range createdOrg.OrgAdmins { admin, ok := admin.(*command.CreatedOrgAdmin) @@ -80,9 +82,9 @@ func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganiza }) } } - return &org.AddOrganizationResponse{ + return connect.NewResponse(&org.AddOrganizationResponse{ Details: object.DomainToDetailsPb(createdOrg.ObjectDetails), OrganizationId: createdOrg.ObjectDetails.ResourceOwner, CreatedAdmins: admins, - }, nil + }), nil } diff --git a/internal/api/grpc/org/v2/org_test.go b/internal/api/grpc/org/v2/org_test.go index 37a3dca41a..564c5597ee 100644 --- a/internal/api/grpc/org/v2/org_test.go +++ b/internal/api/grpc/org/v2/org_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/types/known/timestamppb" @@ -138,7 +139,7 @@ func Test_createdOrganizationToPb(t *testing.T) { tests := []struct { name string args args - want *org.AddOrganizationResponse + want *connect.Response[org.AddOrganizationResponse] wantErr error }{ { @@ -159,7 +160,7 @@ func Test_createdOrganizationToPb(t *testing.T) { }, }, }, - want: &org.AddOrganizationResponse{ + want: connect.NewResponse(&org.AddOrganizationResponse{ Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.New(now), @@ -173,7 +174,7 @@ func Test_createdOrganizationToPb(t *testing.T) { PhoneCode: gu.Ptr("phoneCode"), }, }, - }, + }), }, } for _, tt := range tests { diff --git a/internal/api/grpc/org/v2/query.go b/internal/api/grpc/org/v2/query.go index 27f279d40e..09e2534e8d 100644 --- a/internal/api/grpc/org/v2/query.go +++ b/internal/api/grpc/org/v2/query.go @@ -3,6 +3,8 @@ package org import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" @@ -11,36 +13,36 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/org/v2" ) -func (s *Server) ListOrganizations(ctx context.Context, req *org.ListOrganizationsRequest) (*org.ListOrganizationsResponse, error) { +func (s *Server) ListOrganizations(ctx context.Context, req *connect.Request[org.ListOrganizationsRequest]) (*connect.Response[org.ListOrganizationsResponse], error) { queries, err := listOrgRequestToModel(ctx, req) if err != nil { return nil, err } - orgs, err := s.query.SearchOrgs(ctx, queries, s.checkPermission) + orgs, err := s.query.SearchOrgs(ctx, queries.Msg, s.checkPermission) if err != nil { return nil, err } - return &org.ListOrganizationsResponse{ + return connect.NewResponse(&org.ListOrganizationsResponse{ Result: organizationsToPb(orgs.Orgs), Details: object.ToListDetails(orgs.SearchResponse), - }, nil + }), nil } -func listOrgRequestToModel(ctx context.Context, req *org.ListOrganizationsRequest) (*query.OrgSearchQueries, error) { - offset, limit, asc := object.ListQueryToQuery(req.Query) - queries, err := orgQueriesToQuery(ctx, req.Queries) +func listOrgRequestToModel(ctx context.Context, req *connect.Request[org.ListOrganizationsRequest]) (*connect.Response[query.OrgSearchQueries], error) { + offset, limit, asc := object.ListQueryToQuery(req.Msg.Query) + queries, err := orgQueriesToQuery(ctx, req.Msg.Queries) if err != nil { return nil, err } - return &query.OrgSearchQueries{ + return connect.NewResponse(&query.OrgSearchQueries{ SearchRequest: query.SearchRequest{ Offset: offset, Limit: limit, - SortingColumn: fieldNameToOrganizationColumn(req.SortingColumn), + SortingColumn: fieldNameToOrganizationColumn(req.Msg.SortingColumn), Asc: asc, }, Queries: queries, - }, nil + }), nil } func orgQueriesToQuery(ctx context.Context, queries []*org.SearchQuery) (_ []query.SearchQuery, err error) { diff --git a/internal/api/grpc/org/v2/server.go b/internal/api/grpc/org/v2/server.go index 36588f3eb7..6fd318d114 100644 --- a/internal/api/grpc/org/v2/server.go +++ b/internal/api/grpc/org/v2/server.go @@ -1,7 +1,10 @@ package org import ( - "google.golang.org/grpc" + "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" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/org/v2" + "github.com/zitadel/zitadel/pkg/grpc/org/v2/orgconnect" ) -var _ org.OrganizationServiceServer = (*Server)(nil) +var _ orgconnect.OrganizationServiceHandler = (*Server)(nil) type Server struct { - org.UnimplementedOrganizationServiceServer command *command.Commands query *query.Queries checkPermission domain.PermissionCheck @@ -34,8 +37,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - org.RegisterOrganizationServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return orgconnect.NewOrganizationServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return org.File_zitadel_org_v2_org_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/org/v2beta/helper.go b/internal/api/grpc/org/v2beta/helper.go index 6f47819bb4..77c3130488 100644 --- a/internal/api/grpc/org/v2beta/helper.go +++ b/internal/api/grpc/org/v2beta/helper.go @@ -3,6 +3,7 @@ package org import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" // TODO fix below @@ -71,7 +72,7 @@ func OrgStateToPb(state domain.OrgState) v2beta_org.OrgState { } } -func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.CreateOrganizationResponse, err error) { +func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *connect.Response[org.CreateOrganizationResponse], err error) { admins := make([]*org.OrganizationAdmin, len(createdOrg.OrgAdmins)) for i, admin := range createdOrg.OrgAdmins { switch admin := admin.(type) { @@ -95,11 +96,11 @@ func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.CreateOrgan } } } - return &org.CreateOrganizationResponse{ + return connect.NewResponse(&org.CreateOrganizationResponse{ CreationDate: timestamppb.New(createdOrg.ObjectDetails.EventDate), Id: createdOrg.ObjectDetails.ResourceOwner, OrganizationAdmins: admins, - }, nil + }), nil } func OrgViewsToPb(orgs []*query.Org) []*v2beta_org.Organization { diff --git a/internal/api/grpc/org/v2beta/org.go b/internal/api/grpc/org/v2beta/org.go index 66198757cb..35e1d72d3c 100644 --- a/internal/api/grpc/org/v2beta/org.go +++ b/internal/api/grpc/org/v2beta/org.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" metadata "github.com/zitadel/zitadel/internal/api/grpc/metadata/v2beta" @@ -17,8 +18,8 @@ import ( v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" ) -func (s *Server) CreateOrganization(ctx context.Context, request *v2beta_org.CreateOrganizationRequest) (*v2beta_org.CreateOrganizationResponse, error) { - orgSetup, err := createOrganizationRequestToCommand(request) +func (s *Server) CreateOrganization(ctx context.Context, request *connect.Request[v2beta_org.CreateOrganizationRequest]) (*connect.Response[v2beta_org.CreateOrganizationResponse], error) { + orgSetup, err := createOrganizationRequestToCommand(request.Msg) if err != nil { return nil, err } @@ -29,19 +30,19 @@ func (s *Server) CreateOrganization(ctx context.Context, request *v2beta_org.Cre return createdOrganizationToPb(createdOrg) } -func (s *Server) UpdateOrganization(ctx context.Context, request *v2beta_org.UpdateOrganizationRequest) (*v2beta_org.UpdateOrganizationResponse, error) { - org, err := s.command.ChangeOrg(ctx, request.Id, request.Name) +func (s *Server) UpdateOrganization(ctx context.Context, request *connect.Request[v2beta_org.UpdateOrganizationRequest]) (*connect.Response[v2beta_org.UpdateOrganizationResponse], error) { + org, err := s.command.ChangeOrg(ctx, request.Msg.GetId(), request.Msg.GetName()) if err != nil { return nil, err } - return &v2beta_org.UpdateOrganizationResponse{ + return connect.NewResponse(&v2beta_org.UpdateOrganizationResponse{ ChangeDate: timestamppb.New(org.EventDate), - }, nil + }), nil } -func (s *Server) ListOrganizations(ctx context.Context, request *v2beta_org.ListOrganizationsRequest) (*v2beta_org.ListOrganizationsResponse, error) { - queries, err := listOrgRequestToModel(s.systemDefaults, request) +func (s *Server) ListOrganizations(ctx context.Context, request *connect.Request[v2beta_org.ListOrganizationsRequest]) (*connect.Response[v2beta_org.ListOrganizationsResponse], error) { + queries, err := listOrgRequestToModel(s.systemDefaults, request.Msg) if err != nil { return nil, err } @@ -49,107 +50,107 @@ func (s *Server) ListOrganizations(ctx context.Context, request *v2beta_org.List if err != nil { return nil, err } - return &v2beta_org.ListOrganizationsResponse{ + return connect.NewResponse(&v2beta_org.ListOrganizationsResponse{ Organizations: OrgViewsToPb(orgs.Orgs), Pagination: &filter.PaginationResponse{ TotalResult: orgs.Count, - AppliedLimit: uint64(request.GetPagination().GetLimit()), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), }, - }, nil + }), nil } -func (s *Server) DeleteOrganization(ctx context.Context, request *v2beta_org.DeleteOrganizationRequest) (*v2beta_org.DeleteOrganizationResponse, error) { - details, err := s.command.RemoveOrg(ctx, request.Id) +func (s *Server) DeleteOrganization(ctx context.Context, request *connect.Request[v2beta_org.DeleteOrganizationRequest]) (*connect.Response[v2beta_org.DeleteOrganizationResponse], error) { + details, err := s.command.RemoveOrg(ctx, request.Msg.GetId()) if err != nil { var notFoundError *zerrors.NotFoundError if errors.As(err, ¬FoundError) { - return &v2beta_org.DeleteOrganizationResponse{}, nil + return connect.NewResponse(&v2beta_org.DeleteOrganizationResponse{}), nil } return nil, err } - return &v2beta_org.DeleteOrganizationResponse{ + return connect.NewResponse(&v2beta_org.DeleteOrganizationResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) SetOrganizationMetadata(ctx context.Context, request *v2beta_org.SetOrganizationMetadataRequest) (*v2beta_org.SetOrganizationMetadataResponse, error) { - result, err := s.command.BulkSetOrgMetadata(ctx, request.OrganizationId, BulkSetOrgMetadataToDomain(request)...) +func (s *Server) SetOrganizationMetadata(ctx context.Context, request *connect.Request[v2beta_org.SetOrganizationMetadataRequest]) (*connect.Response[v2beta_org.SetOrganizationMetadataResponse], error) { + result, err := s.command.BulkSetOrgMetadata(ctx, request.Msg.GetOrganizationId(), BulkSetOrgMetadataToDomain(request.Msg)...) if err != nil { return nil, err } - return &org.SetOrganizationMetadataResponse{ + return connect.NewResponse(&org.SetOrganizationMetadataResponse{ SetDate: timestamppb.New(result.EventDate), - }, nil + }), nil } -func (s *Server) ListOrganizationMetadata(ctx context.Context, request *v2beta_org.ListOrganizationMetadataRequest) (*v2beta_org.ListOrganizationMetadataResponse, error) { - metadataQueries, err := ListOrgMetadataToDomain(s.systemDefaults, request) +func (s *Server) ListOrganizationMetadata(ctx context.Context, request *connect.Request[v2beta_org.ListOrganizationMetadataRequest]) (*connect.Response[v2beta_org.ListOrganizationMetadataResponse], error) { + metadataQueries, err := ListOrgMetadataToDomain(s.systemDefaults, request.Msg) if err != nil { return nil, err } - res, err := s.query.SearchOrgMetadata(ctx, true, request.OrganizationId, metadataQueries, false) + res, err := s.query.SearchOrgMetadata(ctx, true, request.Msg.GetOrganizationId(), metadataQueries, false) if err != nil { return nil, err } - return &v2beta_org.ListOrganizationMetadataResponse{ + return connect.NewResponse(&v2beta_org.ListOrganizationMetadataResponse{ Metadata: metadata.OrgMetadataListToPb(res.Metadata), Pagination: &filter.PaginationResponse{ TotalResult: res.Count, - AppliedLimit: uint64(request.GetPagination().GetLimit()), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), }, - }, nil + }), nil } -func (s *Server) DeleteOrganizationMetadata(ctx context.Context, request *v2beta_org.DeleteOrganizationMetadataRequest) (*v2beta_org.DeleteOrganizationMetadataResponse, error) { - result, err := s.command.BulkRemoveOrgMetadata(ctx, request.OrganizationId, request.Keys...) +func (s *Server) DeleteOrganizationMetadata(ctx context.Context, request *connect.Request[v2beta_org.DeleteOrganizationMetadataRequest]) (*connect.Response[v2beta_org.DeleteOrganizationMetadataResponse], error) { + result, err := s.command.BulkRemoveOrgMetadata(ctx, request.Msg.GetOrganizationId(), request.Msg.Keys...) if err != nil { return nil, err } - return &v2beta_org.DeleteOrganizationMetadataResponse{ + return connect.NewResponse(&v2beta_org.DeleteOrganizationMetadataResponse{ DeletionDate: timestamppb.New(result.EventDate), - }, nil + }), nil } -func (s *Server) DeactivateOrganization(ctx context.Context, request *org.DeactivateOrganizationRequest) (*org.DeactivateOrganizationResponse, error) { - objectDetails, err := s.command.DeactivateOrg(ctx, request.Id) +func (s *Server) DeactivateOrganization(ctx context.Context, request *connect.Request[org.DeactivateOrganizationRequest]) (*connect.Response[org.DeactivateOrganizationResponse], error) { + objectDetails, err := s.command.DeactivateOrg(ctx, request.Msg.GetId()) if err != nil { return nil, err } - return &org.DeactivateOrganizationResponse{ + return connect.NewResponse(&org.DeactivateOrganizationResponse{ ChangeDate: timestamppb.New(objectDetails.EventDate), - }, nil + }), nil } -func (s *Server) ActivateOrganization(ctx context.Context, request *org.ActivateOrganizationRequest) (*org.ActivateOrganizationResponse, error) { - objectDetails, err := s.command.ReactivateOrg(ctx, request.Id) +func (s *Server) ActivateOrganization(ctx context.Context, request *connect.Request[org.ActivateOrganizationRequest]) (*connect.Response[org.ActivateOrganizationResponse], error) { + objectDetails, err := s.command.ReactivateOrg(ctx, request.Msg.GetId()) if err != nil { return nil, err } - return &org.ActivateOrganizationResponse{ + return connect.NewResponse(&org.ActivateOrganizationResponse{ ChangeDate: timestamppb.New(objectDetails.EventDate), - }, err + }), err } -func (s *Server) AddOrganizationDomain(ctx context.Context, request *org.AddOrganizationDomainRequest) (*org.AddOrganizationDomainResponse, error) { - userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Domain, request.OrganizationId) +func (s *Server) AddOrganizationDomain(ctx context.Context, request *connect.Request[org.AddOrganizationDomainRequest]) (*connect.Response[org.AddOrganizationDomainResponse], error) { + userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Msg.GetDomain(), request.Msg.GetOrganizationId()) if err != nil { return nil, err } - details, err := s.command.AddOrgDomain(ctx, request.OrganizationId, request.Domain, userIDs) + details, err := s.command.AddOrgDomain(ctx, request.Msg.GetOrganizationId(), request.Msg.GetDomain(), userIDs) if err != nil { return nil, err } - return &org.AddOrganizationDomainResponse{ + return connect.NewResponse(&org.AddOrganizationDomainResponse{ CreationDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) ListOrganizationDomains(ctx context.Context, req *org.ListOrganizationDomainsRequest) (*org.ListOrganizationDomainsResponse, error) { - queries, err := ListOrgDomainsRequestToModel(s.systemDefaults, req) +func (s *Server) ListOrganizationDomains(ctx context.Context, req *connect.Request[org.ListOrganizationDomainsRequest]) (*connect.Response[org.ListOrganizationDomainsResponse], error) { + queries, err := ListOrgDomainsRequestToModel(s.systemDefaults, req.Msg) if err != nil { return nil, err } - orgIDQuery, err := query.NewOrgDomainOrgIDSearchQuery(req.OrganizationId) + orgIDQuery, err := query.NewOrgDomainOrgIDSearchQuery(req.Msg.GetOrganizationId()) if err != nil { return nil, err } @@ -159,48 +160,48 @@ func (s *Server) ListOrganizationDomains(ctx context.Context, req *org.ListOrgan if err != nil { return nil, err } - return &org.ListOrganizationDomainsResponse{ + return connect.NewResponse(&org.ListOrganizationDomainsResponse{ Domains: object.DomainsToPb(domains.Domains), Pagination: &filter.PaginationResponse{ TotalResult: domains.Count, - AppliedLimit: uint64(req.GetPagination().GetLimit()), + AppliedLimit: uint64(req.Msg.GetPagination().GetLimit()), }, - }, nil + }), nil } -func (s *Server) DeleteOrganizationDomain(ctx context.Context, req *org.DeleteOrganizationDomainRequest) (*org.DeleteOrganizationDomainResponse, error) { - details, err := s.command.RemoveOrgDomain(ctx, RemoveOrgDomainRequestToDomain(ctx, req)) +func (s *Server) DeleteOrganizationDomain(ctx context.Context, req *connect.Request[org.DeleteOrganizationDomainRequest]) (*connect.Response[org.DeleteOrganizationDomainResponse], error) { + details, err := s.command.RemoveOrgDomain(ctx, RemoveOrgDomainRequestToDomain(ctx, req.Msg)) if err != nil { return nil, err } - return &org.DeleteOrganizationDomainResponse{ + return connect.NewResponse(&org.DeleteOrganizationDomainResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, err + }), err } -func (s *Server) GenerateOrganizationDomainValidation(ctx context.Context, req *org.GenerateOrganizationDomainValidationRequest) (*org.GenerateOrganizationDomainValidationResponse, error) { - token, url, err := s.command.GenerateOrgDomainValidation(ctx, GenerateOrgDomainValidationRequestToDomain(ctx, req)) +func (s *Server) GenerateOrganizationDomainValidation(ctx context.Context, req *connect.Request[org.GenerateOrganizationDomainValidationRequest]) (*connect.Response[org.GenerateOrganizationDomainValidationResponse], error) { + token, url, err := s.command.GenerateOrgDomainValidation(ctx, GenerateOrgDomainValidationRequestToDomain(ctx, req.Msg)) if err != nil { return nil, err } - return &org.GenerateOrganizationDomainValidationResponse{ + return connect.NewResponse(&org.GenerateOrganizationDomainValidationResponse{ Token: token, Url: url, - }, nil + }), nil } -func (s *Server) VerifyOrganizationDomain(ctx context.Context, request *org.VerifyOrganizationDomainRequest) (*org.VerifyOrganizationDomainResponse, error) { - userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Domain, request.OrganizationId) +func (s *Server) VerifyOrganizationDomain(ctx context.Context, request *connect.Request[org.VerifyOrganizationDomainRequest]) (*connect.Response[org.VerifyOrganizationDomainResponse], error) { + userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Msg.GetDomain(), request.Msg.GetOrganizationId()) if err != nil { return nil, err } - details, err := s.command.ValidateOrgDomain(ctx, ValidateOrgDomainRequestToDomain(ctx, request), userIDs) + details, err := s.command.ValidateOrgDomain(ctx, ValidateOrgDomainRequestToDomain(ctx, request.Msg), userIDs) if err != nil { return nil, err } - return &org.VerifyOrganizationDomainResponse{ + return connect.NewResponse(&org.VerifyOrganizationDomainResponse{ ChangeDate: timestamppb.New(details.EventDate), - }, nil + }), nil } func createOrganizationRequestToCommand(request *v2beta_org.CreateOrganizationRequest) (*command.OrgSetup, error) { diff --git a/internal/api/grpc/org/v2beta/org_test.go b/internal/api/grpc/org/v2beta/org_test.go index 346d6b88c1..85dec79be4 100644 --- a/internal/api/grpc/org/v2beta/org_test.go +++ b/internal/api/grpc/org/v2beta/org_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -138,7 +139,7 @@ func Test_createdOrganizationToPb(t *testing.T) { tests := []struct { name string args args - want *org.CreateOrganizationResponse + want *connect.Response[org.CreateOrganizationResponse] wantErr error }{ { @@ -159,7 +160,7 @@ func Test_createdOrganizationToPb(t *testing.T) { }, }, }, - want: &org.CreateOrganizationResponse{ + want: connect.NewResponse(&org.CreateOrganizationResponse{ CreationDate: timestamppb.New(now), Id: "orgID", OrganizationAdmins: []*org.OrganizationAdmin{ @@ -173,7 +174,7 @@ func Test_createdOrganizationToPb(t *testing.T) { }, }, }, - }, + }), }, } for _, tt := range tests { diff --git a/internal/api/grpc/org/v2beta/server.go b/internal/api/grpc/org/v2beta/server.go index b7e8d4994f..8f9091c7c3 100644 --- a/internal/api/grpc/org/v2beta/server.go +++ b/internal/api/grpc/org/v2beta/server.go @@ -1,7 +1,10 @@ package org import ( - "google.golang.org/grpc" + "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" @@ -10,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/org/v2beta/orgconnect" ) -var _ org.OrganizationServiceServer = (*Server)(nil) +var _ orgconnect.OrganizationServiceHandler = (*Server)(nil) type Server struct { - org.UnimplementedOrganizationServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -38,8 +41,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - org.RegisterOrganizationServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return orgconnect.NewOrganizationServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return org.File_zitadel_org_v2beta_org_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/project/v2beta/integration/query_test.go b/internal/api/grpc/project/v2beta/integration/query_test.go index b648e8c1d7..f8159226c7 100644 --- a/internal/api/grpc/project/v2beta/integration/query_test.go +++ b/internal/api/grpc/project/v2beta/integration/query_test.go @@ -389,7 +389,8 @@ func TestServer_ListProjects(t *testing.T) { resp2 := createProject(iamOwnerCtx, instance, t, orgID, true, false) resp3 := createProject(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &filter.InIDsFilter{Ids: []string{resp1.GetId(), resp2.GetId(), resp3.GetId(), projectResp.GetId()}}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp1.GetId(), resp2.GetId(), resp3.GetId(), projectResp.GetId()}}, } response.Projects[0] = grantedProjectResp response.Projects[1] = projectResp @@ -1225,7 +1226,8 @@ func TestServer_ListProjectGrants(t *testing.T) { project2Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name2, false, false) project3Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name3, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &filter.InIDsFilter{Ids: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId(), projectResp.GetId()}}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId(), projectResp.GetId()}}, } createProjectGrant(iamOwnerCtx, instance, t, orgID, project1Resp.GetId(), name1) diff --git a/internal/api/grpc/project/v2beta/project.go b/internal/api/grpc/project/v2beta/project.go index 01b478f5be..95f08ea61e 100644 --- a/internal/api/grpc/project/v2beta/project.go +++ b/internal/api/grpc/project/v2beta/project.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,8 +14,8 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) CreateProject(ctx context.Context, req *project_pb.CreateProjectRequest) (*project_pb.CreateProjectResponse, error) { - add := projectCreateToCommand(req) +func (s *Server) CreateProject(ctx context.Context, req *connect.Request[project_pb.CreateProjectRequest]) (*connect.Response[project_pb.CreateProjectResponse], error) { + add := projectCreateToCommand(req.Msg) project, err := s.command.AddProject(ctx, add) if err != nil { return nil, err @@ -23,10 +24,10 @@ func (s *Server) CreateProject(ctx context.Context, req *project_pb.CreateProjec if !project.EventDate.IsZero() { creationDate = timestamppb.New(project.EventDate) } - return &project_pb.CreateProjectResponse{ + return connect.NewResponse(&project_pb.CreateProjectResponse{ Id: add.AggregateID, CreationDate: creationDate, - }, nil + }), nil } func projectCreateToCommand(req *project_pb.CreateProjectRequest) *command.AddProject { @@ -60,8 +61,8 @@ func privateLabelingSettingToDomain(setting project_pb.PrivateLabelingSetting) d } } -func (s *Server) UpdateProject(ctx context.Context, req *project_pb.UpdateProjectRequest) (*project_pb.UpdateProjectResponse, error) { - project, err := s.command.ChangeProject(ctx, projectUpdateToCommand(req)) +func (s *Server) UpdateProject(ctx context.Context, req *connect.Request[project_pb.UpdateProjectRequest]) (*connect.Response[project_pb.UpdateProjectResponse], error) { + project, err := s.command.ChangeProject(ctx, projectUpdateToCommand(req.Msg)) if err != nil { return nil, err } @@ -69,9 +70,9 @@ func (s *Server) UpdateProject(ctx context.Context, req *project_pb.UpdateProjec if !project.EventDate.IsZero() { changeDate = timestamppb.New(project.EventDate) } - return &project_pb.UpdateProjectResponse{ + return connect.NewResponse(&project_pb.UpdateProjectResponse{ ChangeDate: changeDate, - }, nil + }), nil } func projectUpdateToCommand(req *project_pb.UpdateProjectRequest) *command.ChangeProject { @@ -91,13 +92,13 @@ func projectUpdateToCommand(req *project_pb.UpdateProjectRequest) *command.Chang } } -func (s *Server) DeleteProject(ctx context.Context, req *project_pb.DeleteProjectRequest) (*project_pb.DeleteProjectResponse, error) { - userGrantIDs, err := s.userGrantsFromProject(ctx, req.Id) +func (s *Server) DeleteProject(ctx context.Context, req *connect.Request[project_pb.DeleteProjectRequest]) (*connect.Response[project_pb.DeleteProjectResponse], error) { + userGrantIDs, err := s.userGrantsFromProject(ctx, req.Msg.GetId()) if err != nil { return nil, err } - deletedAt, err := s.command.DeleteProject(ctx, req.Id, "", userGrantIDs...) + deletedAt, err := s.command.DeleteProject(ctx, req.Msg.GetId(), "", userGrantIDs...) if err != nil { return nil, err } @@ -105,9 +106,9 @@ func (s *Server) DeleteProject(ctx context.Context, req *project_pb.DeleteProjec if !deletedAt.IsZero() { deletionDate = timestamppb.New(deletedAt) } - return &project_pb.DeleteProjectResponse{ + return connect.NewResponse(&project_pb.DeleteProjectResponse{ DeletionDate: deletionDate, - }, nil + }), nil } func (s *Server) userGrantsFromProject(ctx context.Context, projectID string) ([]string, error) { @@ -117,15 +118,15 @@ func (s *Server) userGrantsFromProject(ctx context.Context, projectID string) ([ } userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery}, - }, false) + }, false, nil) if err != nil { return nil, err } return userGrantsToIDs(userGrants.UserGrants), nil } -func (s *Server) DeactivateProject(ctx context.Context, req *project_pb.DeactivateProjectRequest) (*project_pb.DeactivateProjectResponse, error) { - details, err := s.command.DeactivateProject(ctx, req.Id, "") +func (s *Server) DeactivateProject(ctx context.Context, req *connect.Request[project_pb.DeactivateProjectRequest]) (*connect.Response[project_pb.DeactivateProjectResponse], error) { + details, err := s.command.DeactivateProject(ctx, req.Msg.GetId(), "") if err != nil { return nil, err } @@ -133,13 +134,13 @@ func (s *Server) DeactivateProject(ctx context.Context, req *project_pb.Deactiva if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.DeactivateProjectResponse{ + return connect.NewResponse(&project_pb.DeactivateProjectResponse{ ChangeDate: changeDate, - }, nil + }), nil } -func (s *Server) ActivateProject(ctx context.Context, req *project_pb.ActivateProjectRequest) (*project_pb.ActivateProjectResponse, error) { - details, err := s.command.ReactivateProject(ctx, req.Id, "") +func (s *Server) ActivateProject(ctx context.Context, req *connect.Request[project_pb.ActivateProjectRequest]) (*connect.Response[project_pb.ActivateProjectResponse], error) { + details, err := s.command.ReactivateProject(ctx, req.Msg.GetId(), "") if err != nil { return nil, err } @@ -147,9 +148,9 @@ func (s *Server) ActivateProject(ctx context.Context, req *project_pb.ActivatePr if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.ActivateProjectResponse{ + return connect.NewResponse(&project_pb.ActivateProjectResponse{ ChangeDate: changeDate, - }, nil + }), nil } func userGrantsToIDs(userGrants []*query.UserGrant) []string { diff --git a/internal/api/grpc/project/v2beta/project_grant.go b/internal/api/grpc/project/v2beta/project_grant.go index 6c3b195c66..c2ccef1751 100644 --- a/internal/api/grpc/project/v2beta/project_grant.go +++ b/internal/api/grpc/project/v2beta/project_grant.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" @@ -11,8 +12,8 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) CreateProjectGrant(ctx context.Context, req *project_pb.CreateProjectGrantRequest) (*project_pb.CreateProjectGrantResponse, error) { - add := projectGrantCreateToCommand(req) +func (s *Server) CreateProjectGrant(ctx context.Context, req *connect.Request[project_pb.CreateProjectGrantRequest]) (*connect.Response[project_pb.CreateProjectGrantResponse], error) { + add := projectGrantCreateToCommand(req.Msg) project, err := s.command.AddProjectGrant(ctx, add) if err != nil { return nil, err @@ -21,9 +22,9 @@ func (s *Server) CreateProjectGrant(ctx context.Context, req *project_pb.CreateP if !project.EventDate.IsZero() { creationDate = timestamppb.New(project.EventDate) } - return &project_pb.CreateProjectGrantResponse{ + return connect.NewResponse(&project_pb.CreateProjectGrantResponse{ CreationDate: creationDate, - }, nil + }), nil } func projectGrantCreateToCommand(req *project_pb.CreateProjectGrantRequest) *command.AddProjectGrant { @@ -37,8 +38,8 @@ func projectGrantCreateToCommand(req *project_pb.CreateProjectGrantRequest) *com } } -func (s *Server) UpdateProjectGrant(ctx context.Context, req *project_pb.UpdateProjectGrantRequest) (*project_pb.UpdateProjectGrantResponse, error) { - project, err := s.command.ChangeProjectGrant(ctx, projectGrantUpdateToCommand(req)) +func (s *Server) UpdateProjectGrant(ctx context.Context, req *connect.Request[project_pb.UpdateProjectGrantRequest]) (*connect.Response[project_pb.UpdateProjectGrantResponse], error) { + project, err := s.command.ChangeProjectGrant(ctx, projectGrantUpdateToCommand(req.Msg)) if err != nil { return nil, err } @@ -46,9 +47,9 @@ func (s *Server) UpdateProjectGrant(ctx context.Context, req *project_pb.UpdateP if !project.EventDate.IsZero() { changeDate = timestamppb.New(project.EventDate) } - return &project_pb.UpdateProjectGrantResponse{ + return connect.NewResponse(&project_pb.UpdateProjectGrantResponse{ ChangeDate: changeDate, - }, nil + }), nil } func projectGrantUpdateToCommand(req *project_pb.UpdateProjectGrantRequest) *command.ChangeProjectGrant { @@ -61,8 +62,8 @@ func projectGrantUpdateToCommand(req *project_pb.UpdateProjectGrantRequest) *com } } -func (s *Server) DeactivateProjectGrant(ctx context.Context, req *project_pb.DeactivateProjectGrantRequest) (*project_pb.DeactivateProjectGrantResponse, error) { - details, err := s.command.DeactivateProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "") +func (s *Server) DeactivateProjectGrant(ctx context.Context, req *connect.Request[project_pb.DeactivateProjectGrantRequest]) (*connect.Response[project_pb.DeactivateProjectGrantResponse], error) { + details, err := s.command.DeactivateProjectGrant(ctx, req.Msg.GetProjectId(), "", req.Msg.GetGrantedOrganizationId(), "") if err != nil { return nil, err } @@ -70,13 +71,13 @@ func (s *Server) DeactivateProjectGrant(ctx context.Context, req *project_pb.Dea if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.DeactivateProjectGrantResponse{ + return connect.NewResponse(&project_pb.DeactivateProjectGrantResponse{ ChangeDate: changeDate, - }, nil + }), nil } -func (s *Server) ActivateProjectGrant(ctx context.Context, req *project_pb.ActivateProjectGrantRequest) (*project_pb.ActivateProjectGrantResponse, error) { - details, err := s.command.ReactivateProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "") +func (s *Server) ActivateProjectGrant(ctx context.Context, req *connect.Request[project_pb.ActivateProjectGrantRequest]) (*connect.Response[project_pb.ActivateProjectGrantResponse], error) { + details, err := s.command.ReactivateProjectGrant(ctx, req.Msg.GetProjectId(), "", req.Msg.GetGrantedOrganizationId(), "") if err != nil { return nil, err } @@ -84,17 +85,17 @@ func (s *Server) ActivateProjectGrant(ctx context.Context, req *project_pb.Activ if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.ActivateProjectGrantResponse{ + return connect.NewResponse(&project_pb.ActivateProjectGrantResponse{ ChangeDate: changeDate, - }, nil + }), nil } -func (s *Server) DeleteProjectGrant(ctx context.Context, req *project_pb.DeleteProjectGrantRequest) (*project_pb.DeleteProjectGrantResponse, error) { - userGrantIDs, err := s.userGrantsFromProjectGrant(ctx, req.ProjectId, req.GrantedOrganizationId) +func (s *Server) DeleteProjectGrant(ctx context.Context, req *connect.Request[project_pb.DeleteProjectGrantRequest]) (*connect.Response[project_pb.DeleteProjectGrantResponse], error) { + userGrantIDs, err := s.userGrantsFromProjectGrant(ctx, req.Msg.GetProjectId(), req.Msg.GetGrantedOrganizationId()) if err != nil { return nil, err } - details, err := s.command.DeleteProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "", userGrantIDs...) + details, err := s.command.DeleteProjectGrant(ctx, req.Msg.GetProjectId(), "", req.Msg.GetGrantedOrganizationId(), "", userGrantIDs...) if err != nil { return nil, err } @@ -102,9 +103,9 @@ func (s *Server) DeleteProjectGrant(ctx context.Context, req *project_pb.DeleteP if !details.EventDate.IsZero() { deletionDate = timestamppb.New(details.EventDate) } - return &project_pb.DeleteProjectGrantResponse{ + return connect.NewResponse(&project_pb.DeleteProjectGrantResponse{ DeletionDate: deletionDate, - }, nil + }), nil } func (s *Server) userGrantsFromProjectGrant(ctx context.Context, projectID, grantedOrganizationID string) ([]string, error) { @@ -118,7 +119,7 @@ func (s *Server) userGrantsFromProjectGrant(ctx context.Context, projectID, gran } userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery, grantQuery}, - }, false) + }, false, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/project/v2beta/project_role.go b/internal/api/grpc/project/v2beta/project_role.go index 07fc4e9eac..b7105b38c6 100644 --- a/internal/api/grpc/project/v2beta/project_role.go +++ b/internal/api/grpc/project/v2beta/project_role.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" @@ -11,8 +12,8 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) AddProjectRole(ctx context.Context, req *project_pb.AddProjectRoleRequest) (*project_pb.AddProjectRoleResponse, error) { - role, err := s.command.AddProjectRole(ctx, addProjectRoleRequestToCommand(req)) +func (s *Server) AddProjectRole(ctx context.Context, req *connect.Request[project_pb.AddProjectRoleRequest]) (*connect.Response[project_pb.AddProjectRoleResponse], error) { + role, err := s.command.AddProjectRole(ctx, addProjectRoleRequestToCommand(req.Msg)) if err != nil { return nil, err } @@ -20,9 +21,9 @@ func (s *Server) AddProjectRole(ctx context.Context, req *project_pb.AddProjectR if !role.EventDate.IsZero() { creationDate = timestamppb.New(role.EventDate) } - return &project_pb.AddProjectRoleResponse{ + return connect.NewResponse(&project_pb.AddProjectRoleResponse{ CreationDate: creationDate, - }, nil + }), nil } func addProjectRoleRequestToCommand(req *project_pb.AddProjectRoleRequest) *command.AddProjectRole { @@ -41,8 +42,8 @@ func addProjectRoleRequestToCommand(req *project_pb.AddProjectRoleRequest) *comm } } -func (s *Server) UpdateProjectRole(ctx context.Context, req *project_pb.UpdateProjectRoleRequest) (*project_pb.UpdateProjectRoleResponse, error) { - role, err := s.command.ChangeProjectRole(ctx, updateProjectRoleRequestToCommand(req)) +func (s *Server) UpdateProjectRole(ctx context.Context, req *connect.Request[project_pb.UpdateProjectRoleRequest]) (*connect.Response[project_pb.UpdateProjectRoleResponse], error) { + role, err := s.command.ChangeProjectRole(ctx, updateProjectRoleRequestToCommand(req.Msg)) if err != nil { return nil, err } @@ -50,9 +51,9 @@ func (s *Server) UpdateProjectRole(ctx context.Context, req *project_pb.UpdatePr if !role.EventDate.IsZero() { changeDate = timestamppb.New(role.EventDate) } - return &project_pb.UpdateProjectRoleResponse{ + return connect.NewResponse(&project_pb.UpdateProjectRoleResponse{ ChangeDate: changeDate, - }, nil + }), nil } func updateProjectRoleRequestToCommand(req *project_pb.UpdateProjectRoleRequest) *command.ChangeProjectRole { @@ -75,16 +76,16 @@ func updateProjectRoleRequestToCommand(req *project_pb.UpdateProjectRoleRequest) } } -func (s *Server) RemoveProjectRole(ctx context.Context, req *project_pb.RemoveProjectRoleRequest) (*project_pb.RemoveProjectRoleResponse, error) { - userGrantIDs, err := s.userGrantsFromProjectAndRole(ctx, req.ProjectId, req.RoleKey) +func (s *Server) RemoveProjectRole(ctx context.Context, req *connect.Request[project_pb.RemoveProjectRoleRequest]) (*connect.Response[project_pb.RemoveProjectRoleResponse], error) { + userGrantIDs, err := s.userGrantsFromProjectAndRole(ctx, req.Msg.GetProjectId(), req.Msg.GetRoleKey()) if err != nil { return nil, err } - projectGrantIDs, err := s.projectGrantsFromProjectAndRole(ctx, req.ProjectId, req.RoleKey) + projectGrantIDs, err := s.projectGrantsFromProjectAndRole(ctx, req.Msg.GetProjectId(), req.Msg.GetRoleKey()) if err != nil { return nil, err } - details, err := s.command.RemoveProjectRole(ctx, req.ProjectId, req.RoleKey, "", projectGrantIDs, userGrantIDs...) + details, err := s.command.RemoveProjectRole(ctx, req.Msg.GetProjectId(), req.Msg.GetRoleKey(), "", projectGrantIDs, userGrantIDs...) if err != nil { return nil, err } @@ -92,9 +93,9 @@ func (s *Server) RemoveProjectRole(ctx context.Context, req *project_pb.RemovePr if !details.EventDate.IsZero() { deletionDate = timestamppb.New(details.EventDate) } - return &project_pb.RemoveProjectRoleResponse{ + return connect.NewResponse(&project_pb.RemoveProjectRoleResponse{ RemovalDate: deletionDate, - }, nil + }), nil } func (s *Server) userGrantsFromProjectAndRole(ctx context.Context, projectID, roleKey string) ([]string, error) { @@ -108,7 +109,7 @@ func (s *Server) userGrantsFromProjectAndRole(ctx context.Context, projectID, ro } userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery, rolesQuery}, - }, false) + }, false, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/project/v2beta/query.go b/internal/api/grpc/project/v2beta/query.go index 42b69a480e..c736c5a086 100644 --- a/internal/api/grpc/project/v2beta/query.go +++ b/internal/api/grpc/project/v2beta/query.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" @@ -13,18 +14,18 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) GetProject(ctx context.Context, req *project_pb.GetProjectRequest) (*project_pb.GetProjectResponse, error) { - project, err := s.query.GetProjectByIDWithPermission(ctx, true, req.Id, s.checkPermission) +func (s *Server) GetProject(ctx context.Context, req *connect.Request[project_pb.GetProjectRequest]) (*connect.Response[project_pb.GetProjectResponse], error) { + project, err := s.query.GetProjectByIDWithPermission(ctx, true, req.Msg.GetId(), s.checkPermission) if err != nil { return nil, err } - return &project_pb.GetProjectResponse{ + return connect.NewResponse(&project_pb.GetProjectResponse{ Project: projectToPb(project), - }, nil + }), nil } -func (s *Server) ListProjects(ctx context.Context, req *project_pb.ListProjectsRequest) (*project_pb.ListProjectsResponse, error) { - queries, err := s.listProjectRequestToModel(req) +func (s *Server) ListProjects(ctx context.Context, req *connect.Request[project_pb.ListProjectsRequest]) (*connect.Response[project_pb.ListProjectsResponse], error) { + queries, err := s.listProjectRequestToModel(req.Msg) if err != nil { return nil, err } @@ -32,10 +33,10 @@ func (s *Server) ListProjects(ctx context.Context, req *project_pb.ListProjectsR if err != nil { return nil, err } - return &project_pb.ListProjectsResponse{ + return connect.NewResponse(&project_pb.ListProjectsResponse{ Projects: grantedProjectsToPb(resp.GrantedProjects), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } func (s *Server) listProjectRequestToModel(req *project_pb.ListProjectsRequest) (*query.ProjectAndGrantedProjectSearchQueries, error) { @@ -213,8 +214,8 @@ func privateLabelingSettingToPb(setting domain.PrivateLabelingSetting) project_p } } -func (s *Server) ListProjectGrants(ctx context.Context, req *project_pb.ListProjectGrantsRequest) (*project_pb.ListProjectGrantsResponse, error) { - queries, err := s.listProjectGrantsRequestToModel(req) +func (s *Server) ListProjectGrants(ctx context.Context, req *connect.Request[project_pb.ListProjectGrantsRequest]) (*connect.Response[project_pb.ListProjectGrantsResponse], error) { + queries, err := s.listProjectGrantsRequestToModel(req.Msg) if err != nil { return nil, err } @@ -222,10 +223,10 @@ func (s *Server) ListProjectGrants(ctx context.Context, req *project_pb.ListProj if err != nil { return nil, err } - return &project_pb.ListProjectGrantsResponse{ + return connect.NewResponse(&project_pb.ListProjectGrantsResponse{ ProjectGrants: projectGrantsToPb(resp.ProjectGrants), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } func (s *Server) listProjectGrantsRequestToModel(req *project_pb.ListProjectGrantsRequest) (*query.ProjectGrantSearchQueries, error) { @@ -329,12 +330,12 @@ func projectGrantStateToPb(state domain.ProjectGrantState) project_pb.ProjectGra } } -func (s *Server) ListProjectRoles(ctx context.Context, req *project_pb.ListProjectRolesRequest) (*project_pb.ListProjectRolesResponse, error) { - queries, err := s.listProjectRolesRequestToModel(req) +func (s *Server) ListProjectRoles(ctx context.Context, req *connect.Request[project_pb.ListProjectRolesRequest]) (*connect.Response[project_pb.ListProjectRolesResponse], error) { + queries, err := s.listProjectRolesRequestToModel(req.Msg) if err != nil { return nil, err } - err = queries.AppendProjectIDQuery(req.ProjectId) + err = queries.AppendProjectIDQuery(req.Msg.GetProjectId()) if err != nil { return nil, err } @@ -342,10 +343,10 @@ func (s *Server) ListProjectRoles(ctx context.Context, req *project_pb.ListProje if err != nil { return nil, err } - return &project_pb.ListProjectRolesResponse{ + return connect.NewResponse(&project_pb.ListProjectRolesResponse{ ProjectRoles: roleViewsToPb(roles.ProjectRoles), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, roles.SearchResponse), - }, nil + }), nil } func (s *Server) listProjectRolesRequestToModel(req *project_pb.ListProjectRolesRequest) (*query.ProjectRoleSearchQueries, error) { diff --git a/internal/api/grpc/project/v2beta/server.go b/internal/api/grpc/project/v2beta/server.go index fe197f9688..12c18ae4c6 100644 --- a/internal/api/grpc/project/v2beta/server.go +++ b/internal/api/grpc/project/v2beta/server.go @@ -1,21 +1,23 @@ package project import ( - "google.golang.org/grpc" + "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/domain" "github.com/zitadel/zitadel/internal/query" project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/project/v2beta/projectconnect" ) -var _ project.ProjectServiceServer = (*Server)(nil) +var _ projectconnect.ProjectServiceHandler = (*Server)(nil) type Server struct { - project.UnimplementedProjectServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -39,8 +41,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - project.RegisterProjectServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return projectconnect.NewProjectServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return project.File_zitadel_project_v2beta_project_service_proto } func (s *Server) AppName() string { @@ -54,7 +60,3 @@ func (s *Server) MethodPrefix() string { func (s *Server) AuthMethods() authz.MethodMapping { return project.ProjectService_AuthMethods } - -func (s *Server) RegisterGateway() server.RegisterGatewayFunc { - return project.RegisterProjectServiceHandler -} diff --git a/internal/api/grpc/saml/v2/saml.go b/internal/api/grpc/saml/v2/saml.go index 43eae5feb1..5491a5e04b 100644 --- a/internal/api/grpc/saml/v2/saml.go +++ b/internal/api/grpc/saml/v2/saml.go @@ -3,6 +3,7 @@ package saml import ( "context" + "connectrpc.com/connect" "github.com/zitadel/logging" "github.com/zitadel/saml/pkg/provider" "google.golang.org/protobuf/types/known/timestamppb" @@ -16,15 +17,15 @@ import ( saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" ) -func (s *Server) GetSAMLRequest(ctx context.Context, req *saml_pb.GetSAMLRequestRequest) (*saml_pb.GetSAMLRequestResponse, error) { - authRequest, err := s.query.SamlRequestByID(ctx, true, req.GetSamlRequestId(), true) +func (s *Server) GetSAMLRequest(ctx context.Context, req *connect.Request[saml_pb.GetSAMLRequestRequest]) (*connect.Response[saml_pb.GetSAMLRequestResponse], error) { + authRequest, err := s.query.SamlRequestByID(ctx, true, req.Msg.GetSamlRequestId(), true) if err != nil { logging.WithError(err).Error("query samlRequest by ID") return nil, err } - return &saml_pb.GetSAMLRequestResponse{ + return connect.NewResponse(&saml_pb.GetSAMLRequestResponse{ SamlRequest: samlRequestToPb(authRequest), - }, nil + }), nil } func samlRequestToPb(a *query.SamlRequest) *saml_pb.SAMLRequest { @@ -34,18 +35,18 @@ func samlRequestToPb(a *query.SamlRequest) *saml_pb.SAMLRequest { } } -func (s *Server) CreateResponse(ctx context.Context, req *saml_pb.CreateResponseRequest) (*saml_pb.CreateResponseResponse, error) { - switch v := req.GetResponseKind().(type) { +func (s *Server) CreateResponse(ctx context.Context, req *connect.Request[saml_pb.CreateResponseRequest]) (*connect.Response[saml_pb.CreateResponseResponse], error) { + switch v := req.Msg.GetResponseKind().(type) { case *saml_pb.CreateResponseRequest_Error: - return s.failSAMLRequest(ctx, req.GetSamlRequestId(), v.Error) + return s.failSAMLRequest(ctx, req.Msg.GetSamlRequestId(), v.Error) case *saml_pb.CreateResponseRequest_Session: - return s.linkSessionToSAMLRequest(ctx, req.GetSamlRequestId(), v.Session) + return s.linkSessionToSAMLRequest(ctx, req.Msg.GetSamlRequestId(), v.Session) default: return nil, zerrors.ThrowUnimplementedf(nil, "SAMLv2-0Tfak3fBS0", "verification oneOf %T in method CreateResponse not implemented", v) } } -func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae *saml_pb.AuthorizationError) (*saml_pb.CreateResponseResponse, error) { +func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae *saml_pb.AuthorizationError) (*connect.Response[saml_pb.CreateResponseResponse], error) { details, aar, err := s.command.FailSAMLRequest(ctx, samlRequestID, errorReasonToDomain(ae.GetError())) if err != nil { return nil, err @@ -55,7 +56,7 @@ func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae * if err != nil { return nil, err } - return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil + return connect.NewResponse(createCallbackResponseFromBinding(details, url, body, authReq.RelayState)), nil } func (s *Server) checkPermission(ctx context.Context, issuer string, userID string) error { @@ -72,7 +73,7 @@ func (s *Server) checkPermission(ctx context.Context, issuer string, userID stri return nil } -func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*saml_pb.CreateResponseResponse, error) { +func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*connect.Response[saml_pb.CreateResponseResponse], error) { details, aar, err := s.command.LinkSessionToSAMLRequest(ctx, samlRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err @@ -87,7 +88,7 @@ func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID str if err != nil { return nil, err } - return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil + return connect.NewResponse(createCallbackResponseFromBinding(details, url, body, authReq.RelayState)), nil } func createCallbackResponseFromBinding(details *domain.ObjectDetails, url string, body string, relayState string) *saml_pb.CreateResponseResponse { diff --git a/internal/api/grpc/saml/v2/server.go b/internal/api/grpc/saml/v2/server.go index 62299d88c5..312a7c356a 100644 --- a/internal/api/grpc/saml/v2/server.go +++ b/internal/api/grpc/saml/v2/server.go @@ -1,7 +1,10 @@ package saml import ( - "google.golang.org/grpc" + "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" @@ -9,9 +12,10 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" + "github.com/zitadel/zitadel/pkg/grpc/saml/v2/samlconnect" ) -var _ saml_pb.SAMLServiceServer = (*Server)(nil) +var _ samlconnect.SAMLServiceHandler = (*Server)(nil) type Server struct { saml_pb.UnimplementedSAMLServiceServer @@ -38,8 +42,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - saml_pb.RegisterSAMLServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return samlconnect.NewSAMLServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return saml_pb.File_zitadel_saml_v2_saml_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/server/connect_middleware/access_interceptor.go b/internal/api/grpc/server/connect_middleware/access_interceptor.go new file mode 100644 index 0000000000..a08df59860 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/access_interceptor.go @@ -0,0 +1,57 @@ +package connect_middleware + +import ( + "context" + "net/http" + "time" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/logstore" + "github.com/zitadel/zitadel/internal/logstore/record" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func AccessStorageInterceptor(svc *logstore.Service[*record.AccessLog]) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + if !svc.Enabled() { + return handler(ctx, req) + } + resp, handlerErr := handler(ctx, req) + + interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + var respStatus uint32 + if code := connect.CodeOf(handlerErr); code != connect.CodeUnknown { + respStatus = uint32(code) + } + + respHeader := http.Header{} + if resp != nil { + respHeader = resp.Header() + } + instance := authz.GetInstance(ctx) + domainCtx := http_util.DomainContext(ctx) + + r := &record.AccessLog{ + LogDate: time.Now(), + Protocol: record.GRPC, + RequestURL: req.Spec().Procedure, + ResponseStatus: respStatus, + RequestHeaders: req.Header(), + ResponseHeaders: respHeader, + InstanceID: instance.InstanceID(), + ProjectID: instance.ProjectID(), + RequestedDomain: domainCtx.RequestedDomain(), + RequestedHost: domainCtx.RequestedHost(), + } + + svc.Handle(interceptorCtx, r) + return resp, handlerErr + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/activity_interceptor.go b/internal/api/grpc/server/connect_middleware/activity_interceptor.go new file mode 100644 index 0000000000..4ba6044645 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/activity_interceptor.go @@ -0,0 +1,52 @@ +package connect_middleware + +import ( + "context" + "net/http" + "slices" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/activity" + "github.com/zitadel/zitadel/internal/api/grpc/gerrors" + ainfo "github.com/zitadel/zitadel/internal/api/info" +) + +func ActivityInterceptor() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + ctx = activityInfoFromGateway(ctx, req.Header()).SetMethod(req.Spec().Procedure).IntoContext(ctx) + resp, err := handler(ctx, req) + if isResourceAPI(req.Spec().Procedure) { + code, _, _, _ := gerrors.ExtractZITADELError(err) + ctx = ainfo.ActivityInfoFromContext(ctx).SetGRPCStatus(code).IntoContext(ctx) + activity.TriggerGRPCWithContext(ctx, activity.ResourceAPI) + } + return resp, err + } + } +} + +var resourcePrefixes = []string{ + "/zitadel.management.v1.ManagementService/", + "/zitadel.admin.v1.AdminService/", + "/zitadel.user.v2.UserService/", + "/zitadel.settings.v2.SettingsService/", + "/zitadel.user.v2beta.UserService/", + "/zitadel.settings.v2beta.SettingsService/", + "/zitadel.auth.v1.AuthService/", +} + +func isResourceAPI(method string) bool { + return slices.ContainsFunc(resourcePrefixes, func(prefix string) bool { + return strings.HasPrefix(method, prefix) + }) +} + +func activityInfoFromGateway(ctx context.Context, headers http.Header) *ainfo.ActivityInfo { + info := ainfo.ActivityInfoFromContext(ctx) + path := headers.Get(activity.PathKey) + requestMethod := headers.Get(activity.RequestMethodKey) + return info.SetPath(path).SetRequestMethod(requestMethod) +} diff --git a/internal/api/grpc/server/connect_middleware/auth_interceptor.go b/internal/api/grpc/server/connect_middleware/auth_interceptor.go new file mode 100644 index 0000000000..9e500601d0 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/auth_interceptor.go @@ -0,0 +1,65 @@ +package connect_middleware + +import ( + "context" + "errors" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func AuthorizationInterceptor(verifier authz.APITokenVerifier, systemUserPermissions authz.Config, authConfig authz.Config) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return authorize(ctx, req, handler, verifier, systemUserPermissions, authConfig) + } + } +} + +func authorize(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.APITokenVerifier, systemUserPermissions authz.Config, authConfig authz.Config) (_ connect.AnyResponse, err error) { + authOpt, needsToken := verifier.CheckAuthMethod(req.Spec().Procedure) + if !needsToken { + return handler(ctx, req) + } + + authCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + authToken := req.Header().Get(http.Authorization) + if authToken == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("auth header missing")) + } + + orgID, orgDomain := orgIDAndDomainFromRequest(req) + ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, orgDomain, verifier, systemUserPermissions.RolePermissionMappings, authConfig.RolePermissionMappings, authOpt, req.Spec().Procedure) + if err != nil { + return nil, err + } + span.End() + return handler(ctxSetter(ctx), req) +} + +func orgIDAndDomainFromRequest(req connect.AnyRequest) (id, domain string) { + orgID := req.Header().Get(http.ZitadelOrgID) + oz, ok := req.Any().(OrganizationFromRequest) + if ok { + id = oz.OrganizationFromRequestConnect().ID + domain = oz.OrganizationFromRequestConnect().Domain + if id != "" || domain != "" { + return id, domain + } + } + return orgID, domain +} + +type Organization struct { + ID string + Domain string +} + +type OrganizationFromRequest interface { + OrganizationFromRequestConnect() *Organization +} diff --git a/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go b/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go new file mode 100644 index 0000000000..06e716c140 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go @@ -0,0 +1,318 @@ +package connect_middleware + +import ( + "context" + "errors" + "net/http" + "reflect" + "testing" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const anAPIRole = "AN_API_ROLE" + +type authzRepoMock struct{} + +func (v *authzRepoMock) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) { + return "", "", "", "", "", nil +} + +func (v *authzRepoMock) SearchMyMemberships(ctx context.Context, orgID string, _ bool) ([]*authz.Membership, error) { + return authz.Memberships{{ + MemberType: authz.MemberTypeOrganization, + AggregateID: orgID, + Roles: []string{anAPIRole}, + }}, nil +} + +func (v *authzRepoMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) { + return "", nil, nil +} + +func (v *authzRepoMock) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) { + return orgID, nil +} + +func (v *authzRepoMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) { + return "", "", nil +} + +var ( + accessTokenOK = authz.AccessTokenVerifierFunc(func(ctx context.Context, token string) (userID string, clientID string, agentID string, prefLan string, resourceOwner string, err error) { + return "user1", "", "", "", "org1", nil + }) + accessTokenNOK = authz.AccessTokenVerifierFunc(func(ctx context.Context, token string) (userID string, clientID string, agentID string, prefLan string, resourceOwner string, err error) { + return "", "", "", "", "", zerrors.ThrowUnauthenticated(nil, "TEST-fQHDI", "unauthenticaded") + }) + systemTokenNOK = authz.SystemTokenVerifierFunc(func(ctx context.Context, token string, orgID string) (memberships authz.Memberships, userID string, err error) { + return nil, "", errors.New("system token error") + }) +) + +type mockOrgFromRequest struct { + id string +} + +func (m *mockOrgFromRequest) OrganizationFromRequestConnect() *Organization { + return &Organization{ + ID: m.id, + Domain: "", + } +} + +func Test_authorize(t *testing.T) { + type args struct { + ctx context.Context + req connect.AnyRequest + handler func(t *testing.T) connect.UnaryFunc + verifier func() authz.APITokenVerifier + authConfig authz.Config + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "no token needed ok", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/no/token/needed"}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{}) + return verifier + }, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "auth header missing error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication"}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + nil, + true, + }, + }, + { + "unauthorized error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"wrong"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + nil, + true, + }, + }, + { + "authorized ok", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "user1", + OrgID: "org1", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "authorized ok, org by request", + args{ + ctx: context.Background(), + req: &mockReq[mockOrgFromRequest]{ + Request: connect.Request[mockOrgFromRequest]{Msg: &mockOrgFromRequest{"id"}}, + procedure: "/need/authentication", + header: http.Header{"Authorization": []string{"Bearer token"}}, + }, + handler: emptyMockHandler(&connect.Response[mockOrgFromRequest]{Msg: &mockOrgFromRequest{"id"}}, authz.CtxData{ + UserID: "user1", + OrgID: "id", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + &connect.Response[mockOrgFromRequest]{Msg: &mockOrgFromRequest{"id"}}, + false, + }, + }, + { + "permission denied error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "user1", + OrgID: "org1", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: anAPIRole, + Permissions: []string{"to.do.something.else"}, + }}, + }, + }, + res{ + nil, + true, + }, + }, + { + "permission ok", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "user1", + OrgID: "org1", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: anAPIRole, + Permissions: []string{"to.do.something"}, + }}, + }, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "system token permission denied error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenNOK, authz.SystemTokenVerifierFunc(func(ctx context.Context, token string, orgID string) (memberships authz.Memberships, userID string, err error) { + return authz.Memberships{{ + MemberType: authz.MemberTypeSystem, + Roles: []string{"A_SYSTEM_ROLE"}, + }}, "systemuser", nil + })) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: "A_SYSTEM_ROLE", + Permissions: []string{"to.do.something.else"}, + }}, + }, + }, + res{ + nil, + true, + }, + }, + { + "system token permission denied error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "systemuser", + SystemMemberships: authz.Memberships{{ + MemberType: authz.MemberTypeSystem, + Roles: []string{"A_SYSTEM_ROLE"}, + }}, + SystemUserPermissions: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeSystem, + Permissions: []string{"to.do.something"}, + }}, + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenNOK, authz.SystemTokenVerifierFunc(func(ctx context.Context, token string, orgID string) (memberships authz.Memberships, userID string, err error) { + return authz.Memberships{{ + MemberType: authz.MemberTypeSystem, + Roles: []string{"A_SYSTEM_ROLE"}, + }}, "systemuser", nil + })) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: "A_SYSTEM_ROLE", + Permissions: []string{"to.do.something"}, + }}, + }, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := authorize(tt.args.ctx, tt.args.req, tt.args.handler(t), tt.args.verifier(), tt.args.authConfig, tt.args.authConfig) + if (err != nil) != tt.res.wantErr { + t.Errorf("authorize() error = %v, wantErr %v", err, tt.res.wantErr) + return + } + if !reflect.DeepEqual(got, tt.res.want) { + t.Errorf("authorize() got = %v, want %v", got, tt.res.want) + } + }) + } +} diff --git a/internal/api/grpc/server/connect_middleware/cache_interceptor.go b/internal/api/grpc/server/connect_middleware/cache_interceptor.go new file mode 100644 index 0000000000..60ba0032f1 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/cache_interceptor.go @@ -0,0 +1,31 @@ +package connect_middleware + +import ( + "context" + "net/http" + "time" + + "connectrpc.com/connect" + + _ "github.com/zitadel/zitadel/internal/statik" +) + +func NoCacheInterceptor() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + headers := map[string]string{ + "cache-control": "no-store", + "expires": time.Now().UTC().Format(http.TimeFormat), + "pragma": "no-cache", + } + resp, err := handler(ctx, req) + if err != nil { + return nil, err + } + for key, value := range headers { + resp.Header().Set(key, value) + } + return resp, err + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/call_interceptor.go b/internal/api/grpc/server/connect_middleware/call_interceptor.go new file mode 100644 index 0000000000..cc74e10f85 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/call_interceptor.go @@ -0,0 +1,18 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/call" +) + +func CallDurationHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + ctx = call.WithTimestamp(ctx) + return handler(ctx, req) + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/error_interceptor.go b/internal/api/grpc/server/connect_middleware/error_interceptor.go new file mode 100644 index 0000000000..9aef95bc6d --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/error_interceptor.go @@ -0,0 +1,23 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/grpc/gerrors" + _ "github.com/zitadel/zitadel/internal/statik" +) + +func ErrorHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return toConnectError(ctx, req, handler) + } + } +} + +func toConnectError(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc) (connect.AnyResponse, error) { + resp, err := handler(ctx, req) + return resp, gerrors.ZITADELToConnectError(err) // TODO ! +} diff --git a/internal/api/grpc/server/connect_middleware/error_interceptor_test.go b/internal/api/grpc/server/connect_middleware/error_interceptor_test.go new file mode 100644 index 0000000000..954f2fd58f --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/error_interceptor_test.go @@ -0,0 +1,65 @@ +package connect_middleware + +import ( + "context" + "reflect" + "testing" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" +) + +func Test_toGRPCError(t *testing.T) { + type args struct { + ctx context.Context + req connect.AnyRequest + handler func(t *testing.T) connect.UnaryFunc + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "no error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{}, + handler: errorMockHandler(), + }, + res{ + nil, + true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toConnectError(tt.args.ctx, tt.args.req, tt.args.handler(t)) + if (err != nil) != tt.res.wantErr { + t.Errorf("toGRPCError() error = %v, wantErr %v", err, tt.res.wantErr) + return + } + if !reflect.DeepEqual(got, tt.res.want) { + t.Errorf("toGRPCError() got = %v, want %v", got, tt.res.want) + } + }) + } +} diff --git a/internal/api/grpc/server/connect_middleware/execution_interceptor.go b/internal/api/grpc/server/connect_middleware/execution_interceptor.go new file mode 100644 index 0000000000..879496a33f --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/execution_interceptor.go @@ -0,0 +1,160 @@ +package connect_middleware + +import ( + "context" + "encoding/json" + + "connectrpc.com/connect" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/execution" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func ExecutionHandler(queries *query.Queries) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + requestTargets, responseTargets := execution.QueryExecutionTargetsForRequestAndResponse(ctx, queries, req.Spec().Procedure) + + // call targets otherwise return req + handledReq, err := executeTargetsForRequest(ctx, requestTargets, req.Spec().Procedure, req) + if err != nil { + return nil, err + } + + response, err := handler(ctx, handledReq) + if err != nil { + return nil, err + } + + return executeTargetsForResponse(ctx, responseTargets, req.Spec().Procedure, handledReq, response) + } + } +} + +func executeTargetsForRequest(ctx context.Context, targets []execution.Target, fullMethod string, req connect.AnyRequest) (_ connect.AnyRequest, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + // if no targets are found, return without any calls + if len(targets) == 0 { + return req, nil + } + + ctxData := authz.GetCtxData(ctx) + info := &ContextInfoRequest{ + FullMethod: fullMethod, + InstanceID: authz.GetInstance(ctx).InstanceID(), + ProjectID: ctxData.ProjectID, + OrgID: ctxData.OrgID, + UserID: ctxData.UserID, + Request: Message{req.Any().(proto.Message)}, + } + + _, err = execution.CallTargets(ctx, targets, info) + if err != nil { + return nil, err + } + return req, nil +} + +func executeTargetsForResponse(ctx context.Context, targets []execution.Target, fullMethod string, req connect.AnyRequest, resp connect.AnyResponse) (_ connect.AnyResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + // if no targets are found, return without any calls + if len(targets) == 0 { + return resp, nil + } + + ctxData := authz.GetCtxData(ctx) + info := &ContextInfoResponse{ + FullMethod: fullMethod, + InstanceID: authz.GetInstance(ctx).InstanceID(), + ProjectID: ctxData.ProjectID, + OrgID: ctxData.OrgID, + UserID: ctxData.UserID, + Request: Message{req.Any().(proto.Message)}, + Response: Message{resp.Any().(proto.Message)}, + } + + _, err = execution.CallTargets(ctx, targets, info) + if err != nil { + return nil, err + } + return resp, nil +} + +var _ execution.ContextInfo = &ContextInfoRequest{} + +type ContextInfoRequest struct { + FullMethod string `json:"fullMethod,omitempty"` + InstanceID string `json:"instanceID,omitempty"` + OrgID string `json:"orgID,omitempty"` + ProjectID string `json:"projectID,omitempty"` + UserID string `json:"userID,omitempty"` + Request Message `json:"request,omitempty"` +} + +type Message struct { + proto.Message +} + +func (r *Message) MarshalJSON() ([]byte, error) { + data, err := protojson.Marshal(r.Message) + if err != nil { + return nil, err + } + return data, nil +} + +func (r *Message) UnmarshalJSON(data []byte) error { + return protojson.Unmarshal(data, r.Message) +} + +func (c *ContextInfoRequest) GetHTTPRequestBody() []byte { + data, err := json.Marshal(c) + if err != nil { + return nil + } + return data +} + +func (c *ContextInfoRequest) SetHTTPResponseBody(resp []byte) error { + return json.Unmarshal(resp, &c.Request) +} + +func (c *ContextInfoRequest) GetContent() interface{} { + return c.Request.Message +} + +var _ execution.ContextInfo = &ContextInfoResponse{} + +type ContextInfoResponse struct { + FullMethod string `json:"fullMethod,omitempty"` + InstanceID string `json:"instanceID,omitempty"` + OrgID string `json:"orgID,omitempty"` + ProjectID string `json:"projectID,omitempty"` + UserID string `json:"userID,omitempty"` + Request Message `json:"request,omitempty"` + Response Message `json:"response,omitempty"` +} + +func (c *ContextInfoResponse) GetHTTPRequestBody() []byte { + data, err := json.Marshal(c) + if err != nil { + return nil + } + return data +} + +func (c *ContextInfoResponse) SetHTTPResponseBody(resp []byte) error { + return json.Unmarshal(resp, &c.Response) +} + +func (c *ContextInfoResponse) GetContent() interface{} { + return c.Response.Message +} diff --git a/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go b/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go new file mode 100644 index 0000000000..d910824f21 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go @@ -0,0 +1,815 @@ +package connect_middleware + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/execution" +) + +var _ execution.Target = &mockExecutionTarget{} + +type mockExecutionTarget struct { + InstanceID string + ExecutionID string + TargetID string + TargetType domain.TargetType + Endpoint string + Timeout time.Duration + InterruptOnError bool + SigningKey string +} + +func (e *mockExecutionTarget) SetEndpoint(endpoint string) { + e.Endpoint = endpoint +} +func (e *mockExecutionTarget) IsInterruptOnError() bool { + return e.InterruptOnError +} +func (e *mockExecutionTarget) GetEndpoint() string { + return e.Endpoint +} +func (e *mockExecutionTarget) GetTargetType() domain.TargetType { + return e.TargetType +} +func (e *mockExecutionTarget) GetTimeout() time.Duration { + return e.Timeout +} +func (e *mockExecutionTarget) GetTargetID() string { + return e.TargetID +} +func (e *mockExecutionTarget) GetExecutionID() string { + return e.ExecutionID +} +func (e *mockExecutionTarget) GetSigningKey() string { + return e.SigningKey +} + +func newMockContentRequest(content string) *connect.Request[structpb.Struct] { + return connect.NewRequest(&structpb.Struct{ + Fields: map[string]*structpb.Value{ + "content": { + Kind: &structpb.Value_StringValue{StringValue: content}, + }, + }, + }) +} + +func newMockContentResponse(content string) *connect.Response[structpb.Struct] { + return connect.NewResponse(&structpb.Struct{ + Fields: map[string]*structpb.Value{ + "content": { + Kind: &structpb.Value_StringValue{StringValue: content}, + }, + }, + }) +} + +func newMockContextInfoRequest(fullMethod, request string) *ContextInfoRequest { + return &ContextInfoRequest{ + FullMethod: fullMethod, + Request: Message{Message: newMockContentRequest(request).Msg}, + } +} + +func newMockContextInfoResponse(fullMethod, request, response string) *ContextInfoResponse { + return &ContextInfoResponse{ + FullMethod: fullMethod, + Request: Message{Message: newMockContentRequest(request).Msg}, + Response: Message{Message: newMockContentResponse(response).Msg}, + } +} + +func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { + type target struct { + reqBody execution.ContextInfo + sleep time.Duration + statusCode int + respBody connect.AnyResponse + } + type args struct { + ctx context.Context + + executionTargets []execution.Target + targets []target + fullMethod string + req connect.AnyRequest + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "target, executionTargets nil", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: nil, + req: newMockContentRequest("request"), + }, + res{ + want: newMockContentRequest("request"), + }, + }, + { + "target, executionTargets empty", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{}, + req: newMockContentRequest("request"), + }, + res{ + want: newMockContentRequest("request"), + }, + }, + { + "target, not reachable", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + }, + }, + targets: []target{}, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, error without interrupt", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusBadRequest, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "target, interruptOnError", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusBadRequest, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, wrong request", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + {reqBody: newMockContextInfoRequest("/service/method", "wrong")}, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content1"), + }, + }, + { + "target async, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeAsync, + Timeout: time.Second, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "target async, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeAsync, + Timeout: time.Minute, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "webhook, error", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeWebhook, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + sleep: 0, + statusCode: http.StatusInternalServerError, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "webhook, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeWebhook, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "webhook, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeWebhook, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "with includes, interruptOnError", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target1", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target2", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target3", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content1"), + respBody: newMockContentResponse("content2"), + sleep: 0, + statusCode: http.StatusBadRequest, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content2"), + respBody: newMockContentResponse("content3"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "with includes, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target1", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target2", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target3", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content1"), + respBody: newMockContentResponse("content2"), + sleep: 5 * time.Second, + statusCode: http.StatusBadRequest, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content2"), + respBody: newMockContentResponse("content3"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + closeFuncs := make([]func(), len(tt.args.targets)) + for i, target := range tt.args.targets { + url, closeF := testServerCall( + target.reqBody, + target.sleep, + target.statusCode, + target.respBody, + ) + + et := tt.args.executionTargets[i].(*mockExecutionTarget) + et.SetEndpoint(url) + closeFuncs[i] = closeF + } + + resp, err := executeTargetsForRequest( + tt.args.ctx, + tt.args.executionTargets, + tt.args.fullMethod, + tt.args.req, + ) + + if tt.res.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.EqualExportedValues(t, tt.res.want, resp) + + for _, closeF := range closeFuncs { + closeF() + } + }) + } +} + +func testServerCall( + reqBody interface{}, + sleep time.Duration, + statusCode int, + respBody connect.AnyResponse, +) (string, func()) { + handler := func(w http.ResponseWriter, r *http.Request) { + data, err := json.Marshal(reqBody) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + sentBody, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + if !reflect.DeepEqual(data, sentBody) { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + if statusCode != http.StatusOK { + http.Error(w, "error", statusCode) + return + } + + time.Sleep(sleep) + + w.Header().Set("Content-Type", "application/json") + resp, err := protojson.Marshal(respBody.Any().(proto.Message)) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if _, err := w.Write(resp); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + + return server.URL, server.Close +} + +func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { + type target struct { + reqBody execution.ContextInfo + sleep time.Duration + statusCode int + respBody connect.AnyResponse + } + type args struct { + ctx context.Context + + executionTargets []execution.Target + targets []target + fullMethod string + req connect.AnyRequest + resp connect.AnyResponse + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "target, executionTargets nil", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: nil, + req: newMockContentRequest("request"), + resp: newMockContentResponse("response"), + }, + res{ + want: newMockContentResponse("response"), + }, + }, + { + "target, executionTargets empty", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{}, + req: newMockContentRequest("request"), + resp: newMockContentResponse("response"), + }, + res{ + want: newMockContentResponse("response"), + }, + }, + { + "target, empty response", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse(""), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest(""), + resp: newMockContentResponse(""), + }, + res{ + wantErr: true, + }, + }, + { + "target, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "response./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoResponse("/service/method", "request", "response"), + respBody: newMockContentResponse("response1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("request"), + resp: newMockContentResponse("response"), + }, + res{ + want: newMockContentResponse("response1"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + closeFuncs := make([]func(), len(tt.args.targets)) + for i, target := range tt.args.targets { + url, closeF := testServerCall( + target.reqBody, + target.sleep, + target.statusCode, + target.respBody, + ) + + et := tt.args.executionTargets[i].(*mockExecutionTarget) + et.SetEndpoint(url) + closeFuncs[i] = closeF + } + + resp, err := executeTargetsForResponse( + tt.args.ctx, + tt.args.executionTargets, + tt.args.fullMethod, + tt.args.req, + tt.args.resp, + ) + + if tt.res.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.EqualExportedValues(t, tt.res.want, resp) + + for _, closeF := range closeFuncs { + closeF() + } + }) + } +} diff --git a/internal/api/grpc/server/connect_middleware/instance_interceptor.go b/internal/api/grpc/server/connect_middleware/instance_interceptor.go new file mode 100644 index 0000000000..27f59313f8 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/instance_interceptor.go @@ -0,0 +1,107 @@ +package connect_middleware + +import ( + "context" + "errors" + "fmt" + "strings" + + "connectrpc.com/connect" + "github.com/zitadel/logging" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + zitadel_http "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" + object_v3 "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" +) + +func InstanceInterceptor(verifier authz.InstanceVerifier, externalDomain string, explicitInstanceIdServices ...string) connect.UnaryInterceptorFunc { + translator, err := i18n.NewZitadelTranslator(language.English) + logging.OnError(err).Panic("unable to get translator") + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return setInstance(ctx, req, handler, verifier, externalDomain, translator, explicitInstanceIdServices...) + } + } +} + +func setInstance(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, externalDomain string, translator *i18n.Translator, idFromRequestsServices ...string) (_ connect.AnyResponse, err error) { + interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + for _, service := range idFromRequestsServices { + if !strings.HasPrefix(service, "/") { + service = "/" + service + } + if strings.HasPrefix(req.Spec().Procedure, service) { + withInstanceIDProperty, ok := req.Any().(interface { + GetInstanceId() string + }) + if !ok { + return handler(ctx, req) + } + return addInstanceByID(interceptorCtx, req, handler, verifier, translator, withInstanceIDProperty.GetInstanceId()) + } + } + explicitInstanceRequest, ok := req.Any().(interface { + GetInstance() *object_v3.Instance + }) + if ok { + instance := explicitInstanceRequest.GetInstance() + if id := instance.GetId(); id != "" { + return addInstanceByID(interceptorCtx, req, handler, verifier, translator, id) + } + if domain := instance.GetDomain(); domain != "" { + return addInstanceByDomain(interceptorCtx, req, handler, verifier, translator, domain) + } + } + return addInstanceByRequestedHost(interceptorCtx, req, handler, verifier, translator, externalDomain) +} + +func addInstanceByID(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, id string) (connect.AnyResponse, error) { + instance, err := verifier.InstanceByID(ctx, id) + if err != nil { + notFoundErr := new(zerrors.ZitadelError) + if errors.As(err, ¬FoundErr) { + notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil) + } + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using id %s: %w", id, notFoundErr)) + } + return handler(authz.WithInstance(ctx, instance), req) +} + +func addInstanceByDomain(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, domain string) (connect.AnyResponse, error) { + instance, err := verifier.InstanceByHost(ctx, domain, "") + if err != nil { + notFoundErr := new(zerrors.NotFoundError) + if errors.As(err, ¬FoundErr) { + notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil) + } + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using domain %s: %w", domain, notFoundErr)) + } + return handler(authz.WithInstance(ctx, instance), req) +} + +func addInstanceByRequestedHost(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, externalDomain string) (connect.AnyResponse, error) { + requestContext := zitadel_http.DomainContext(ctx) + if requestContext.InstanceHost == "" { + logging.WithFields("origin", requestContext.Origin(), "externalDomain", externalDomain).Error("unable to set instance") + return nil, connect.NewError(connect.CodeNotFound, errors.New("no instanceHost specified")) + } + instance, err := verifier.InstanceByHost(ctx, requestContext.InstanceHost, requestContext.PublicHost) + if err != nil { + origin := zitadel_http.DomainContext(ctx) + logging.WithFields("origin", requestContext.Origin(), "externalDomain", externalDomain).WithError(err).Error("unable to set instance") + zErr := new(zerrors.ZitadelError) + if errors.As(err, &zErr) { + zErr.SetMessage(translator.LocalizeFromCtx(ctx, zErr.GetMessage(), nil)) + zErr.Parent = err + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using origin %s (ExternalDomain is %s): %s", origin, externalDomain, zErr.Error())) + } + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using origin %s (ExternalDomain is %s)", origin, externalDomain)) + } + return handler(authz.WithInstance(ctx, instance), req) +} diff --git a/internal/api/grpc/server/connect_middleware/limits_interceptor.go b/internal/api/grpc/server/connect_middleware/limits_interceptor.go new file mode 100644 index 0000000000..abf7e5f0aa --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/limits_interceptor.go @@ -0,0 +1,34 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func LimitsInterceptor(ignoreService ...string) connect.UnaryInterceptorFunc { + for idx, service := range ignoreService { + if !strings.HasPrefix(service, "/") { + ignoreService[idx] = "/" + service + } + } + + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + for _, service := range ignoreService { + if strings.HasPrefix(req.Spec().Procedure, service) { + return handler(ctx, req) + } + } + instance := authz.GetInstance(ctx) + if block := instance.Block(); block != nil && *block { + return nil, zerrors.ThrowResourceExhausted(nil, "LIMITS-molsj", "Errors.Limits.Instance.Blocked") + } + return handler(ctx, req) + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/metrics_interceptor.go b/internal/api/grpc/server/connect_middleware/metrics_interceptor.go new file mode 100644 index 0000000000..552fa5658d --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/metrics_interceptor.go @@ -0,0 +1,96 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/zitadel/logging" + "go.opentelemetry.io/otel/attribute" + "google.golang.org/grpc/codes" + + _ "github.com/zitadel/zitadel/internal/statik" + "github.com/zitadel/zitadel/internal/telemetry/metrics" +) + +const ( + GrpcMethod = "grpc_method" + ReturnCode = "return_code" + GrpcRequestCounter = "grpc.server.request_counter" + GrpcRequestCounterDescription = "Grpc request counter" + TotalGrpcRequestCounter = "grpc.server.total_request_counter" + TotalGrpcRequestCounterDescription = "Total grpc request counter" + GrpcStatusCodeCounter = "grpc.server.grpc_status_code" + GrpcStatusCodeCounterDescription = "Grpc status code counter" +) + +func MetricsHandler(metricTypes []metrics.MetricType, ignoredMethodSuffixes ...string) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return RegisterMetrics(ctx, req, handler, metricTypes, ignoredMethodSuffixes...) + } + } +} + +func RegisterMetrics(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, metricTypes []metrics.MetricType, ignoredMethodSuffixes ...string) (_ connect.AnyResponse, err error) { + if len(metricTypes) == 0 { + return handler(ctx, req) + } + + for _, ignore := range ignoredMethodSuffixes { + if strings.HasSuffix(req.Spec().Procedure, ignore) { + return handler(ctx, req) + } + } + + resp, err := handler(ctx, req) + if containsMetricsMethod(metrics.MetricTypeRequestCount, metricTypes) { + RegisterGrpcRequestCounter(ctx, req.Spec().Procedure) + } + if containsMetricsMethod(metrics.MetricTypeTotalCount, metricTypes) { + RegisterGrpcTotalRequestCounter(ctx) + } + if containsMetricsMethod(metrics.MetricTypeStatusCode, metricTypes) { + RegisterGrpcRequestCodeCounter(ctx, req.Spec().Procedure, err) + } + return resp, err +} + +func RegisterGrpcRequestCounter(ctx context.Context, path string) { + var labels = map[string]attribute.Value{ + GrpcMethod: attribute.StringValue(path), + } + err := metrics.RegisterCounter(GrpcRequestCounter, GrpcRequestCounterDescription) + logging.OnError(err).Warn("failed to register grpc request counter") + err = metrics.AddCount(ctx, GrpcRequestCounter, 1, labels) + logging.OnError(err).Warn("failed to add grpc request count") +} + +func RegisterGrpcTotalRequestCounter(ctx context.Context) { + err := metrics.RegisterCounter(TotalGrpcRequestCounter, TotalGrpcRequestCounterDescription) + logging.OnError(err).Warn("failed to register total grpc request counter") + err = metrics.AddCount(ctx, TotalGrpcRequestCounter, 1, nil) + logging.OnError(err).Warn("failed to add total grpc request count") +} + +func RegisterGrpcRequestCodeCounter(ctx context.Context, path string, err error) { + statusCode := connect.CodeOf(err) + var labels = map[string]attribute.Value{ + GrpcMethod: attribute.StringValue(path), + ReturnCode: attribute.IntValue(runtime.HTTPStatusFromCode(codes.Code(statusCode))), + } + err = metrics.RegisterCounter(GrpcStatusCodeCounter, GrpcStatusCodeCounterDescription) + logging.OnError(err).Warn("failed to register grpc status code counter") + err = metrics.AddCount(ctx, GrpcStatusCodeCounter, 1, labels) + logging.OnError(err).Warn("failed to add grpc status code count") +} + +func containsMetricsMethod(metricType metrics.MetricType, metricTypes []metrics.MetricType) bool { + for _, m := range metricTypes { + if m == metricType { + return true + } + } + return false +} diff --git a/internal/api/grpc/server/connect_middleware/mock_test.go b/internal/api/grpc/server/connect_middleware/mock_test.go new file mode 100644 index 0000000000..abd996b01f --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/mock_test.go @@ -0,0 +1,50 @@ +package connect_middleware + +import ( + "context" + "net/http" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func emptyMockHandler(resp connect.AnyResponse, expectedCtxData authz.CtxData) func(*testing.T) connect.UnaryFunc { + return func(t *testing.T) connect.UnaryFunc { + return func(ctx context.Context, _ connect.AnyRequest) (connect.AnyResponse, error) { + assert.Equal(t, expectedCtxData, authz.GetCtxData(ctx)) + return resp, nil + } + } +} + +func errorMockHandler() func(*testing.T) connect.UnaryFunc { + return func(t *testing.T) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return nil, zerrors.ThrowInternal(nil, "test", "error") + } + } +} + +type mockReq[t any] struct { + connect.Request[t] + + procedure string + header http.Header +} + +func (m *mockReq[T]) Spec() connect.Spec { + return connect.Spec{ + Procedure: m.procedure, + } +} + +func (m *mockReq[T]) Header() http.Header { + if m.header == nil { + m.header = make(http.Header) + } + return m.header +} diff --git a/internal/api/grpc/server/connect_middleware/quota_interceptor.go b/internal/api/grpc/server/connect_middleware/quota_interceptor.go new file mode 100644 index 0000000000..caa32511e4 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/quota_interceptor.go @@ -0,0 +1,53 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/logstore" + "github.com/zitadel/zitadel/internal/logstore/record" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func QuotaExhaustedInterceptor(svc *logstore.Service[*record.AccessLog], ignoreService ...string) connect.UnaryInterceptorFunc { + for idx, service := range ignoreService { + if !strings.HasPrefix(service, "/") { + ignoreService[idx] = "/" + service + } + } + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + if !svc.Enabled() { + return handler(ctx, req) + } + interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + // The auth interceptor will ensure that only authorized or public requests are allowed. + // So if there's no authorization context, we don't need to check for limitation + // Also, we don't limit calls with system user tokens + ctxData := authz.GetCtxData(ctx) + if ctxData.IsZero() || ctxData.SystemMemberships != nil { + return handler(ctx, req) + } + + for _, service := range ignoreService { + if strings.HasPrefix(req.Spec().Procedure, service) { + return handler(ctx, req) + } + } + + instance := authz.GetInstance(ctx) + remaining := svc.Limit(interceptorCtx, instance.InstanceID()) + if remaining != nil && *remaining == 0 { + return nil, zerrors.ThrowResourceExhausted(nil, "QUOTA-vjAy8", "Quota.Access.Exhausted") + } + span.End() + return handler(ctx, req) + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/service_interceptor.go b/internal/api/grpc/server/connect_middleware/service_interceptor.go new file mode 100644 index 0000000000..c5cf798ce5 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/service_interceptor.go @@ -0,0 +1,45 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/service" + _ "github.com/zitadel/zitadel/internal/statik" +) + +const ( + unknown = "UNKNOWN" +) + +func ServiceHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + serviceName, _ := serviceAndMethod(req.Spec().Procedure) + if serviceName != unknown { + return handler(ctx, req) + } + ctx = service.WithService(ctx, serviceName) + return handler(ctx, req) + } + } +} + +// serviceAndMethod returns the service and method from a procedure. +func serviceAndMethod(procedure string) (string, string) { + procedure = strings.TrimPrefix(procedure, "/") + serviceName, method := unknown, unknown + if strings.Contains(procedure, "/") { + long := strings.Split(procedure, "/")[0] + if strings.Contains(long, ".") { + split := strings.Split(long, ".") + serviceName = split[len(split)-1] + } + } + if strings.Contains(procedure, "/") { + method = strings.Split(procedure, "/")[1] + } + return serviceName, method +} diff --git a/internal/api/grpc/server/connect_middleware/translation_interceptor.go b/internal/api/grpc/server/connect_middleware/translation_interceptor.go new file mode 100644 index 0000000000..f01b1c85ab --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/translation_interceptor.go @@ -0,0 +1,48 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/i18n" + _ "github.com/zitadel/zitadel/internal/statik" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func TranslationHandler() connect.UnaryInterceptorFunc { + + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + resp, err := handler(ctx, req) + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if err != nil { + translator, translatorError := getTranslator(ctx) + if translatorError != nil { + return resp, err + } + return resp, translateError(ctx, err, translator) + } + if loc, ok := resp.Any().(localizers); ok { + translator, translatorError := getTranslator(ctx) + if translatorError != nil { + return resp, err + } + translateFields(ctx, loc, translator) + } + return resp, nil + } + } +} + +func getTranslator(ctx context.Context) (*i18n.Translator, error) { + translator, err := i18n.NewZitadelTranslator(authz.GetInstance(ctx).DefaultLanguage()) + if err != nil { + logging.New().WithError(err).Error("could not load translator") + } + return translator, err +} diff --git a/internal/api/grpc/server/connect_middleware/translator.go b/internal/api/grpc/server/connect_middleware/translator.go new file mode 100644 index 0000000000..6d61b1d772 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/translator.go @@ -0,0 +1,37 @@ +package connect_middleware + +import ( + "context" + "errors" + + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type localizers interface { + Localizers() []Localizer +} +type Localizer interface { + LocalizationKey() string + SetLocalizedMessage(string) +} + +func translateFields(ctx context.Context, object localizers, translator *i18n.Translator) { + if translator == nil || object == nil { + return + } + for _, field := range object.Localizers() { + field.SetLocalizedMessage(translator.LocalizeFromCtx(ctx, field.LocalizationKey(), nil)) + } +} + +func translateError(ctx context.Context, err error, translator *i18n.Translator) error { + if translator == nil || err == nil { + return err + } + caosErr := new(zerrors.ZitadelError) + if errors.As(err, &caosErr) { + caosErr.SetMessage(translator.LocalizeFromCtx(ctx, caosErr.GetMessage(), nil)) + } + return err +} diff --git a/internal/api/grpc/server/connect_middleware/validation_interceptor.go b/internal/api/grpc/server/connect_middleware/validation_interceptor.go new file mode 100644 index 0000000000..8441886114 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/validation_interceptor.go @@ -0,0 +1,36 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + // import to make sure go.mod does not lose it + // because dependency is only needed for generated code + _ "github.com/envoyproxy/protoc-gen-validate/validate" +) + +func ValidationHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return validate(ctx, req, handler) + } + } +} + +// validator interface needed for github.com/envoyproxy/protoc-gen-validate +// (it does not expose an interface itself) +type validator interface { + Validate() error +} + +func validate(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc) (connect.AnyResponse, error) { + validate, ok := req.Any().(validator) + if !ok { + return handler(ctx, req) + } + err := validate.Validate() + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return handler(ctx, req) +} diff --git a/internal/api/grpc/server/gateway.go b/internal/api/grpc/server/gateway.go index ca7579ee89..b20819b850 100644 --- a/internal/api/grpc/server/gateway.go +++ b/internal/api/grpc/server/gateway.go @@ -171,7 +171,7 @@ func CreateGateway( }, nil } -func RegisterGateway(ctx context.Context, gateway *Gateway, server Server) error { +func RegisterGateway(ctx context.Context, gateway *Gateway, server WithGateway) error { err := server.RegisterGateway()(ctx, gateway.mux, gateway.connection) if err != nil { return fmt.Errorf("failed to register grpc gateway: %w", err) diff --git a/internal/api/grpc/server/server.go b/internal/api/grpc/server/server.go index b686d3add9..0c02087c89 100644 --- a/internal/api/grpc/server/server.go +++ b/internal/api/grpc/server/server.go @@ -2,11 +2,14 @@ package server import ( "crypto/tls" + "net/http" + "connectrpc.com/connect" grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" "google.golang.org/grpc" "google.golang.org/grpc/credentials" healthpb "google.golang.org/grpc/health/grpc_health_v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" grpc_api "github.com/zitadel/zitadel/internal/api/grpc" @@ -19,21 +22,36 @@ import ( ) type Server interface { - RegisterServer(*grpc.Server) - RegisterGateway() RegisterGatewayFunc AppName() string MethodPrefix() string AuthMethods() authz.MethodMapping } +type GrpcServer interface { + Server + RegisterServer(*grpc.Server) +} + +type WithGateway interface { + Server + RegisterGateway() RegisterGatewayFunc +} + // WithGatewayPrefix extends the server interface with a prefix for the grpc gateway // // it's used for the System, Admin, Mgmt and Auth API type WithGatewayPrefix interface { - Server + GrpcServer + WithGateway GatewayPathPrefix() string } +type ConnectServer interface { + Server + RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) + FileDescriptor() protoreflect.FileDescriptor +} + func CreateServer( verifier authz.APITokenVerifier, systemAuthz authz.Config, diff --git a/internal/api/grpc/session/v2/query.go b/internal/api/grpc/session/v2/query.go index 73303dd9e8..78d8623ee7 100644 --- a/internal/api/grpc/session/v2/query.go +++ b/internal/api/grpc/session/v2/query.go @@ -4,6 +4,7 @@ import ( "context" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -26,18 +27,18 @@ var ( } ) -func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { - res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken(), s.checkPermission) +func (s *Server) GetSession(ctx context.Context, req *connect.Request[session.GetSessionRequest]) (*connect.Response[session.GetSessionResponse], error) { + res, err := s.query.SessionByID(ctx, true, req.Msg.GetSessionId(), req.Msg.GetSessionToken(), s.checkPermission) if err != nil { return nil, err } - return &session.GetSessionResponse{ + return connect.NewResponse(&session.GetSessionResponse{ Session: sessionToPb(res), - }, nil + }), nil } -func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) { - queries, err := listSessionsRequestToQuery(ctx, req) +func (s *Server) ListSessions(ctx context.Context, req *connect.Request[session.ListSessionsRequest]) (*connect.Response[session.ListSessionsResponse], error) { + queries, err := listSessionsRequestToQuery(ctx, req.Msg) if err != nil { return nil, err } @@ -45,10 +46,10 @@ func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequ if err != nil { return nil, err } - return &session.ListSessionsResponse{ + return connect.NewResponse(&session.ListSessionsResponse{ Details: object.ToListDetails(sessions.SearchResponse), Sessions: sessionsToPb(sessions.Sessions), - }, nil + }), nil } func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) { diff --git a/internal/api/grpc/session/v2/server.go b/internal/api/grpc/session/v2/server.go index ee534cb26c..8f06cb3fb0 100644 --- a/internal/api/grpc/session/v2/server.go +++ b/internal/api/grpc/session/v2/server.go @@ -1,7 +1,10 @@ package session import ( - "google.golang.org/grpc" + "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" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/session/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2/sessionconnect" ) -var _ session.SessionServiceServer = (*Server)(nil) +var _ sessionconnect.SessionServiceHandler = (*Server)(nil) type Server struct { - session.UnimplementedSessionServiceServer command *command.Commands query *query.Queries @@ -35,8 +38,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - session.RegisterSessionServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return sessionconnect.NewSessionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return session.File_zitadel_session_v2_session_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index 08f19368ef..94f686a72c 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "connectrpc.com/connect" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" @@ -17,12 +18,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) -func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) { - checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req) +func (s *Server) CreateSession(ctx context.Context, req *connect.Request[session.CreateSessionRequest]) (*connect.Response[session.CreateSessionResponse], error) { + checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } @@ -32,43 +33,43 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe return nil, err } - return &session.CreateSessionResponse{ + return connect.NewResponse(&session.CreateSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionId: set.ID, SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) { - checks, err := s.setSessionRequestToCommand(ctx, req) +func (s *Server) SetSession(ctx context.Context, req *connect.Request[session.SetSessionRequest]) (*connect.Response[session.SetSessionResponse], error) { + checks, err := s.setSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } - set, err := s.command.UpdateSession(ctx, req.GetSessionId(), cmds, req.GetMetadata(), req.GetLifetime().AsDuration()) + set, err := s.command.UpdateSession(ctx, req.Msg.GetSessionId(), cmds, req.Msg.GetMetadata(), req.Msg.GetLifetime().AsDuration()) if err != nil { return nil, err } - return &session.SetSessionResponse{ + return connect.NewResponse(&session.SetSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) { - details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken()) +func (s *Server) DeleteSession(ctx context.Context, req *connect.Request[session.DeleteSessionRequest]) (*connect.Response[session.DeleteSessionResponse], error) { + details, err := s.command.TerminateSession(ctx, req.Msg.GetSessionId(), req.Msg.GetSessionToken()) if err != nil { return nil, err } - return &session.DeleteSessionResponse{ + return connect.NewResponse(&session.DeleteSessionResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCommand, map[string][]byte, *domain.UserAgent, time.Duration, error) { diff --git a/internal/api/grpc/session/v2beta/server.go b/internal/api/grpc/session/v2beta/server.go index cf0d0c27f0..e659b406eb 100644 --- a/internal/api/grpc/session/v2beta/server.go +++ b/internal/api/grpc/session/v2beta/server.go @@ -1,7 +1,10 @@ package session import ( - "google.golang.org/grpc" + "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" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/session/v2beta/sessionconnect" ) -var _ session.SessionServiceServer = (*Server)(nil) +var _ sessionconnect.SessionServiceHandler = (*Server)(nil) type Server struct { - session.UnimplementedSessionServiceServer command *command.Commands query *query.Queries @@ -35,8 +38,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - session.RegisterSessionServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return sessionconnect.NewSessionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return session.File_zitadel_session_v2beta_session_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/session/v2beta/session.go b/internal/api/grpc/session/v2beta/session.go index 3b36b8ba83..459cf77f05 100644 --- a/internal/api/grpc/session/v2beta/session.go +++ b/internal/api/grpc/session/v2beta/session.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" @@ -31,18 +32,18 @@ var ( } ) -func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { - res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken(), s.checkPermission) +func (s *Server) GetSession(ctx context.Context, req *connect.Request[session.GetSessionRequest]) (*connect.Response[session.GetSessionResponse], error) { + res, err := s.query.SessionByID(ctx, true, req.Msg.GetSessionId(), req.Msg.GetSessionToken(), s.checkPermission) if err != nil { return nil, err } - return &session.GetSessionResponse{ + return connect.NewResponse(&session.GetSessionResponse{ Session: sessionToPb(res), - }, nil + }), nil } -func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) { - queries, err := listSessionsRequestToQuery(ctx, req) +func (s *Server) ListSessions(ctx context.Context, req *connect.Request[session.ListSessionsRequest]) (*connect.Response[session.ListSessionsResponse], error) { + queries, err := listSessionsRequestToQuery(ctx, req.Msg) if err != nil { return nil, err } @@ -50,18 +51,18 @@ func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequ if err != nil { return nil, err } - return &session.ListSessionsResponse{ + return connect.NewResponse(&session.ListSessionsResponse{ Details: object.ToListDetails(sessions.SearchResponse), Sessions: sessionsToPb(sessions.Sessions), - }, nil + }), nil } -func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) { - checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req) +func (s *Server) CreateSession(ctx context.Context, req *connect.Request[session.CreateSessionRequest]) (*connect.Response[session.CreateSessionResponse], error) { + checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } @@ -71,43 +72,43 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe return nil, err } - return &session.CreateSessionResponse{ + return connect.NewResponse(&session.CreateSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionId: set.ID, SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) { - checks, err := s.setSessionRequestToCommand(ctx, req) +func (s *Server) SetSession(ctx context.Context, req *connect.Request[session.SetSessionRequest]) (*connect.Response[session.SetSessionResponse], error) { + checks, err := s.setSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } - set, err := s.command.UpdateSession(ctx, req.GetSessionId(), cmds, req.GetMetadata(), req.GetLifetime().AsDuration()) + set, err := s.command.UpdateSession(ctx, req.Msg.GetSessionId(), cmds, req.Msg.GetMetadata(), req.Msg.GetLifetime().AsDuration()) if err != nil { return nil, err } - return &session.SetSessionResponse{ + return connect.NewResponse(&session.SetSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) { - details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken()) +func (s *Server) DeleteSession(ctx context.Context, req *connect.Request[session.DeleteSessionRequest]) (*connect.Response[session.DeleteSessionResponse], error) { + details, err := s.command.TerminateSession(ctx, req.Msg.GetSessionId(), req.Msg.GetSessionToken()) if err != nil { return nil, err } - return &session.DeleteSessionResponse{ + return connect.NewResponse(&session.DeleteSessionResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func sessionsToPb(sessions []*query.Session) []*session.Session { diff --git a/internal/api/grpc/settings/v2/query.go b/internal/api/grpc/settings/v2/query.go index b8994ccb87..d522424040 100644 --- a/internal/api/grpc/settings/v2/query.go +++ b/internal/api/grpc/settings/v2/query.go @@ -3,6 +3,7 @@ package settings import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" @@ -14,12 +15,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { - current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLoginSettings(ctx context.Context, req *connect.Request[settings.GetLoginSettingsRequest]) (*connect.Response[settings.GetLoginSettingsResponse], error) { + current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLoginSettingsResponse{ + return connect.NewResponse(&settings.GetLoginSettingsResponse{ Settings: loginSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -27,15 +28,15 @@ func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSet ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.OrgID, }, - }, nil + }), nil } -func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { - current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *connect.Request[settings.GetPasswordComplexitySettingsRequest]) (*connect.Response[settings.GetPasswordComplexitySettingsResponse], error) { + current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordComplexitySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordComplexitySettingsResponse{ Settings: passwordComplexitySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -43,15 +44,15 @@ func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *setting ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { - current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *connect.Request[settings.GetPasswordExpirySettingsRequest]) (*connect.Response[settings.GetPasswordExpirySettingsResponse], error) { + current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordExpirySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordExpirySettingsResponse{ Settings: passwordExpirySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -59,15 +60,15 @@ func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.Ge ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { - current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetBrandingSettings(ctx context.Context, req *connect.Request[settings.GetBrandingSettingsRequest]) (*connect.Response[settings.GetBrandingSettingsResponse], error) { + current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetBrandingSettingsResponse{ + return connect.NewResponse(&settings.GetBrandingSettingsResponse{ Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -75,15 +76,15 @@ func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrand ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { - current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetDomainSettings(ctx context.Context, req *connect.Request[settings.GetDomainSettingsRequest]) (*connect.Response[settings.GetDomainSettingsResponse], error) { + current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetDomainSettingsResponse{ + return connect.NewResponse(&settings.GetDomainSettingsResponse{ Settings: domainSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -91,15 +92,15 @@ func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainS ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { - current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *connect.Request[settings.GetLegalAndSupportSettingsRequest]) (*connect.Response[settings.GetLegalAndSupportSettingsResponse], error) { + current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLegalAndSupportSettingsResponse{ + return connect.NewResponse(&settings.GetLegalAndSupportSettingsResponse{ Settings: legalAndSupportSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -107,15 +108,15 @@ func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.G ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { - current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx())) +func (s *Server) GetLockoutSettings(ctx context.Context, req *connect.Request[settings.GetLockoutSettingsRequest]) (*connect.Response[settings.GetLockoutSettingsResponse], error) { + current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx())) if err != nil { return nil, err } - return &settings.GetLockoutSettingsResponse{ + return connect.NewResponse(&settings.GetLockoutSettingsResponse{ Settings: lockoutSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -123,24 +124,24 @@ func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockou ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { - queries, err := activeIdentityProvidersToQuery(req) +func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *connect.Request[settings.GetActiveIdentityProvidersRequest]) (*connect.Response[settings.GetActiveIdentityProvidersResponse], error) { + queries, err := activeIdentityProvidersToQuery(req.Msg) if err != nil { return nil, err } - links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) if err != nil { return nil, err } - return &settings.GetActiveIdentityProvidersResponse{ + return connect.NewResponse(&settings.GetActiveIdentityProvidersResponse{ Details: object.ToListDetails(links.SearchResponse), IdentityProviders: identityProvidersToPb(links.Links), - }, nil + }), nil } func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequest) (_ []query.SearchQuery, err error) { @@ -180,30 +181,30 @@ func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequ return q, nil } -func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { +func (s *Server) GetGeneralSettings(ctx context.Context, _ *connect.Request[settings.GetGeneralSettingsRequest]) (*connect.Response[settings.GetGeneralSettingsResponse], error) { instance := authz.GetInstance(ctx) - return &settings.GetGeneralSettingsResponse{ + return connect.NewResponse(&settings.GetGeneralSettingsResponse{ SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), DefaultOrgId: instance.DefaultOrganisationID(), DefaultLanguage: instance.DefaultLanguage().String(), - }, nil + }), nil } -func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { +func (s *Server) GetSecuritySettings(ctx context.Context, req *connect.Request[settings.GetSecuritySettingsRequest]) (*connect.Response[settings.GetSecuritySettingsResponse], error) { policy, err := s.query.SecurityPolicy(ctx) if err != nil { return nil, err } - return &settings.GetSecuritySettingsResponse{ + return connect.NewResponse(&settings.GetSecuritySettingsResponse{ Settings: securityPolicyToSettingsPb(policy), - }, nil + }), nil } -func (s *Server) GetHostedLoginTranslation(ctx context.Context, req *settings.GetHostedLoginTranslationRequest) (*settings.GetHostedLoginTranslationResponse, error) { - translation, err := s.query.GetHostedLoginTranslation(ctx, req) +func (s *Server) GetHostedLoginTranslation(ctx context.Context, req *connect.Request[settings.GetHostedLoginTranslationRequest]) (*connect.Response[settings.GetHostedLoginTranslationResponse], error) { + translation, err := s.query.GetHostedLoginTranslation(ctx, req.Msg) if err != nil { return nil, err } - return translation, nil + return connect.NewResponse(translation), nil } diff --git a/internal/api/grpc/settings/v2/server.go b/internal/api/grpc/settings/v2/server.go index 9cae50824f..bfaec17fc2 100644 --- a/internal/api/grpc/settings/v2/server.go +++ b/internal/api/grpc/settings/v2/server.go @@ -2,8 +2,10 @@ package settings import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/assets" "github.com/zitadel/zitadel/internal/api/authz" @@ -11,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2/settingsconnect" ) -var _ settings.SettingsServiceServer = (*Server)(nil) +var _ settingsconnect.SettingsServiceHandler = (*Server)(nil) type Server struct { - settings.UnimplementedSettingsServiceServer command *command.Commands query *query.Queries assetsAPIDomain func(context.Context) string @@ -35,8 +37,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - settings.RegisterSettingsServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return settingsconnect.NewSettingsServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return settings.File_zitadel_settings_v2_settings_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go index 09ee6b27c8..c7db200211 100644 --- a/internal/api/grpc/settings/v2/settings.go +++ b/internal/api/grpc/settings/v2/settings.go @@ -3,25 +3,27 @@ package settings import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecuritySettingsRequest) (*settings.SetSecuritySettingsResponse, error) { - details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req)) +func (s *Server) SetSecuritySettings(ctx context.Context, req *connect.Request[settings.SetSecuritySettingsRequest]) (*connect.Response[settings.SetSecuritySettingsResponse], error) { + details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req.Msg)) if err != nil { return nil, err } - return &settings.SetSecuritySettingsResponse{ + return connect.NewResponse(&settings.SetSecuritySettingsResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *settings.SetHostedLoginTranslationRequest) (*settings.SetHostedLoginTranslationResponse, error) { - res, err := s.command.SetHostedLoginTranslation(ctx, req) +func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *connect.Request[settings.SetHostedLoginTranslationRequest]) (*connect.Response[settings.SetHostedLoginTranslationResponse], error) { + res, err := s.command.SetHostedLoginTranslation(ctx, req.Msg) if err != nil { return nil, err } - return res, nil + return connect.NewResponse(res), nil } diff --git a/internal/api/grpc/settings/v2beta/server.go b/internal/api/grpc/settings/v2beta/server.go index 24c8f7774a..a8200a7216 100644 --- a/internal/api/grpc/settings/v2beta/server.go +++ b/internal/api/grpc/settings/v2beta/server.go @@ -2,8 +2,10 @@ package settings import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/assets" "github.com/zitadel/zitadel/internal/api/authz" @@ -11,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta/settingsconnect" ) -var _ settings.SettingsServiceServer = (*Server)(nil) +var _ settingsconnect.SettingsServiceHandler = (*Server)(nil) type Server struct { - settings.UnimplementedSettingsServiceServer command *command.Commands query *query.Queries assetsAPIDomain func(context.Context) string @@ -35,8 +37,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - settings.RegisterSettingsServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return settingsconnect.NewSettingsServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return settings.File_zitadel_settings_v2beta_settings_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/settings/v2beta/settings.go b/internal/api/grpc/settings/v2beta/settings.go index 6193f129ba..53d2c37c32 100644 --- a/internal/api/grpc/settings/v2beta/settings.go +++ b/internal/api/grpc/settings/v2beta/settings.go @@ -3,6 +3,7 @@ package settings import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" @@ -14,12 +15,12 @@ import ( settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" ) -func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { - current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLoginSettings(ctx context.Context, req *connect.Request[settings.GetLoginSettingsRequest]) (*connect.Response[settings.GetLoginSettingsResponse], error) { + current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLoginSettingsResponse{ + return connect.NewResponse(&settings.GetLoginSettingsResponse{ Settings: loginSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -27,15 +28,15 @@ func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSet ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.OrgID, }, - }, nil + }), nil } -func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { - current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *connect.Request[settings.GetPasswordComplexitySettingsRequest]) (*connect.Response[settings.GetPasswordComplexitySettingsResponse], error) { + current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordComplexitySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordComplexitySettingsResponse{ Settings: passwordComplexitySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -43,15 +44,15 @@ func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *setting ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { - current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *connect.Request[settings.GetPasswordExpirySettingsRequest]) (*connect.Response[settings.GetPasswordExpirySettingsResponse], error) { + current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordExpirySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordExpirySettingsResponse{ Settings: passwordExpirySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -59,15 +60,15 @@ func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.Ge ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { - current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetBrandingSettings(ctx context.Context, req *connect.Request[settings.GetBrandingSettingsRequest]) (*connect.Response[settings.GetBrandingSettingsResponse], error) { + current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetBrandingSettingsResponse{ + return connect.NewResponse(&settings.GetBrandingSettingsResponse{ Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -75,15 +76,15 @@ func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrand ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { - current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetDomainSettings(ctx context.Context, req *connect.Request[settings.GetDomainSettingsRequest]) (*connect.Response[settings.GetDomainSettingsResponse], error) { + current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetDomainSettingsResponse{ + return connect.NewResponse(&settings.GetDomainSettingsResponse{ Settings: domainSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -91,15 +92,15 @@ func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainS ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { - current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *connect.Request[settings.GetLegalAndSupportSettingsRequest]) (*connect.Response[settings.GetLegalAndSupportSettingsResponse], error) { + current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLegalAndSupportSettingsResponse{ + return connect.NewResponse(&settings.GetLegalAndSupportSettingsResponse{ Settings: legalAndSupportSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -107,15 +108,15 @@ func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.G ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { - current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx())) +func (s *Server) GetLockoutSettings(ctx context.Context, req *connect.Request[settings.GetLockoutSettingsRequest]) (*connect.Response[settings.GetLockoutSettingsResponse], error) { + current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx())) if err != nil { return nil, err } - return &settings.GetLockoutSettingsResponse{ + return connect.NewResponse(&settings.GetLockoutSettingsResponse{ Settings: lockoutSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -123,46 +124,46 @@ func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockou ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { - links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false) +func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *connect.Request[settings.GetActiveIdentityProvidersRequest]) (*connect.Response[settings.GetActiveIdentityProvidersResponse], error) { + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false) if err != nil { return nil, err } - return &settings.GetActiveIdentityProvidersResponse{ + return connect.NewResponse(&settings.GetActiveIdentityProvidersResponse{ Details: object.ToListDetails(links.SearchResponse), IdentityProviders: identityProvidersToPb(links.Links), - }, nil + }), nil } -func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { +func (s *Server) GetGeneralSettings(ctx context.Context, _ *connect.Request[settings.GetGeneralSettingsRequest]) (*connect.Response[settings.GetGeneralSettingsResponse], error) { instance := authz.GetInstance(ctx) - return &settings.GetGeneralSettingsResponse{ + return connect.NewResponse(&settings.GetGeneralSettingsResponse{ SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), DefaultOrgId: instance.DefaultOrganisationID(), DefaultLanguage: instance.DefaultLanguage().String(), - }, nil + }), nil } -func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { +func (s *Server) GetSecuritySettings(ctx context.Context, req *connect.Request[settings.GetSecuritySettingsRequest]) (*connect.Response[settings.GetSecuritySettingsResponse], error) { policy, err := s.query.SecurityPolicy(ctx) if err != nil { return nil, err } - return &settings.GetSecuritySettingsResponse{ + return connect.NewResponse(&settings.GetSecuritySettingsResponse{ Settings: securityPolicyToSettingsPb(policy), - }, nil + }), nil } -func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecuritySettingsRequest) (*settings.SetSecuritySettingsResponse, error) { - details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req)) +func (s *Server) SetSecuritySettings(ctx context.Context, req *connect.Request[settings.SetSecuritySettingsRequest]) (*connect.Response[settings.SetSecuritySettingsResponse], error) { + details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req.Msg)) if err != nil { return nil, err } - return &settings.SetSecuritySettingsResponse{ + return connect.NewResponse(&settings.SetSecuritySettingsResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } diff --git a/internal/api/grpc/system/instance.go b/internal/api/grpc/system/instance.go index a5dd7b81bc..ccfcfecbf3 100644 --- a/internal/api/grpc/system/instance.go +++ b/internal/api/grpc/system/instance.go @@ -40,7 +40,7 @@ func (s *Server) GetInstance(ctx context.Context, req *system_pb.GetInstanceRequ } func (s *Server) AddInstance(ctx context.Context, req *system_pb.AddInstanceRequest) (*system_pb.AddInstanceResponse, error) { - id, _, _, details, err := s.command.SetUpInstance(ctx, AddInstancePbToSetupInstance(req, s.defaultInstance, s.externalDomain)) + id, _, _, _, details, err := s.command.SetUpInstance(ctx, AddInstancePbToSetupInstance(req, s.defaultInstance, s.externalDomain)) if err != nil { return nil, err } @@ -61,7 +61,7 @@ func (s *Server) UpdateInstance(ctx context.Context, req *system_pb.UpdateInstan } func (s *Server) CreateInstance(ctx context.Context, req *system_pb.CreateInstanceRequest) (*system_pb.CreateInstanceResponse, error) { - id, pat, key, details, err := s.command.SetUpInstance(ctx, CreateInstancePbToSetupInstance(req, s.defaultInstance, s.externalDomain)) + id, pat, key, _, details, err := s.command.SetUpInstance(ctx, CreateInstancePbToSetupInstance(req, s.defaultInstance, s.externalDomain)) if err != nil { return nil, err } diff --git a/internal/api/grpc/user/v2/email.go b/internal/api/grpc/user/v2/email.go index 4b247ef10f..df68e58c7d 100644 --- a/internal/api/grpc/user/v2/email.go +++ b/internal/api/grpc/user/v2/email.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) { +func (s *Server) SetEmail(ctx context.Context, req *connect.Request[user.SetEmailRequest]) (resp *connect.Response[user.SetEmailResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetEmailRequest_SendCode: - email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.SetEmailRequest_ReturnCode: - email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmailReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) case *user.SetEmailRequest_IsVerified: - email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), req.GetEmail()) + email, err = s.command.ChangeUserEmailVerified(ctx, req.Msg.GetUserId(), req.Msg.GetEmail()) case nil: - email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmail(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v) } @@ -30,26 +31,26 @@ func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp return nil, err } - return &user.SetEmailResponse{ + return connect.NewResponse(&user.SetEmailResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeRequest) (resp *user.ResendEmailCodeResponse, err error) { +func (s *Server) ResendEmailCode(ctx context.Context, req *connect.Request[user.ResendEmailCodeRequest]) (resp *connect.Response[user.ResendEmailCodeResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendEmailCodeRequest_SendCode: - email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.Msg.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.ResendEmailCodeRequest_ReturnCode: - email, err = s.command.ResendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - email, err = s.command.ResendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method ResendEmailCode not implemented", v) } @@ -57,26 +58,26 @@ func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeR return nil, err } - return &user.ResendEmailCodeResponse{ + return connect.NewResponse(&user.ResendEmailCodeResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) SendEmailCode(ctx context.Context, req *user.SendEmailCodeRequest) (resp *user.SendEmailCodeResponse, err error) { +func (s *Server) SendEmailCode(ctx context.Context, req *connect.Request[user.SendEmailCodeRequest]) (resp *connect.Response[user.SendEmailCodeResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SendEmailCodeRequest_SendCode: - email, err = s.command.SendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.SendUserEmailCodeURLTemplate(ctx, req.Msg.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.SendEmailCodeRequest_ReturnCode: - email, err = s.command.SendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.SendUserEmailReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - email, err = s.command.SendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.SendUserEmailCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method SendEmailCode not implemented", v) } @@ -84,30 +85,30 @@ func (s *Server) SendEmailCode(ctx context.Context, req *user.SendEmailCodeReque return nil, err } - return &user.SendEmailCodeResponse{ + return connect.NewResponse(&user.SendEmailCodeResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) { +func (s *Server) VerifyEmail(ctx context.Context, req *connect.Request[user.VerifyEmailRequest]) (*connect.Response[user.VerifyEmailResponse], error) { details, err := s.command.VerifyUserEmail(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyEmailResponse{ + return connect.NewResponse(&user.VerifyEmailResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/human.go b/internal/api/grpc/user/v2/human.go index d8a0891396..03af0f75be 100644 --- a/internal/api/grpc/user/v2/human.go +++ b/internal/api/grpc/user/v2/human.go @@ -4,6 +4,7 @@ import ( "context" "io" + "connectrpc.com/connect" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/timestamppb" @@ -14,7 +15,14 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string) (*user.CreateUserResponse, error) { +func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string) (*connect.Response[user.CreateUserResponse], error) { + metadataEntries := make([]*user.SetMetadataEntry, len(humanPb.Metadata)) + for i, metadataEntry := range humanPb.Metadata { + metadataEntries[i] = &user.SetMetadataEntry{ + Key: metadataEntry.GetKey(), + Value: metadataEntry.GetValue(), + } + } addHumanPb := &user.AddHumanUserRequest{ Username: userName, UserId: userId, @@ -26,6 +34,7 @@ func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUs Phone: humanPb.Phone, IdpLinks: humanPb.IdpLinks, TotpSecret: humanPb.TotpSecret, + Metadata: metadataEntries, } switch pwType := humanPb.GetPasswordType().(type) { case *user.CreateUserRequest_Human_HashedPassword: @@ -52,15 +61,15 @@ func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUs ); err != nil { return nil, err } - return &user.CreateUserResponse{ + return connect.NewResponse(&user.CreateUserResponse{ Id: newHuman.ID, CreationDate: timestamppb.New(newHuman.Details.EventDate), EmailCode: newHuman.EmailCode, PhoneCode: newHuman.PhoneCode, - }, nil + }), nil } -func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUserRequest_Human, userId string, userName *string) (*user.UpdateUserResponse, error) { +func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUserRequest_Human, userId string, userName *string) (*connect.Response[user.UpdateUserResponse], error) { cmd, err := updateHumanUserToCommand(userId, userName, humanPb) if err != nil { return nil, err @@ -68,11 +77,11 @@ func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUs if err = s.command.ChangeUserHuman(ctx, cmd, s.userCodeAlg); err != nil { return nil, err } - return &user.UpdateUserResponse{ + return connect.NewResponse(&user.UpdateUserResponse{ ChangeDate: timestamppb.New(cmd.Details.EventDate), EmailCode: cmd.EmailCode, PhoneCode: cmd.PhoneCode, - }, nil + }), nil } func updateHumanUserToCommand(userId string, userName *string, human *user.UpdateUserRequest_Human) (*command.ChangeHuman, error) { diff --git a/internal/api/grpc/user/v2/idp_link.go b/internal/api/grpc/user/v2/idp_link.go index bef40617cf..0b1e7ab998 100644 --- a/internal/api/grpc/user/v2/idp_link.go +++ b/internal/api/grpc/user/v2/idp_link.go @@ -3,6 +3,8 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" @@ -11,22 +13,22 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { - details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{ - IDPID: req.GetIdpLink().GetIdpId(), - DisplayName: req.GetIdpLink().GetUserName(), - IDPExternalID: req.GetIdpLink().GetUserId(), +func (s *Server) AddIDPLink(ctx context.Context, req *connect.Request[user.AddIDPLinkRequest]) (_ *connect.Response[user.AddIDPLinkResponse], err error) { + details, err := s.command.AddUserIDPLink(ctx, req.Msg.GetUserId(), "", &command.AddLink{ + IDPID: req.Msg.GetIdpLink().GetIdpId(), + DisplayName: req.Msg.GetIdpLink().GetUserName(), + IDPExternalID: req.Msg.GetIdpLink().GetUserId(), }) if err != nil { return nil, err } - return &user.AddIDPLinkResponse{ + return connect.NewResponse(&user.AddIDPLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ListIDPLinks(ctx context.Context, req *user.ListIDPLinksRequest) (_ *user.ListIDPLinksResponse, err error) { - queries, err := ListLinkedIDPsRequestToQuery(req) +func (s *Server) ListIDPLinks(ctx context.Context, req *connect.Request[user.ListIDPLinksRequest]) (_ *connect.Response[user.ListIDPLinksResponse], err error) { + queries, err := ListLinkedIDPsRequestToQuery(req.Msg) if err != nil { return nil, err } @@ -34,10 +36,10 @@ func (s *Server) ListIDPLinks(ctx context.Context, req *user.ListIDPLinksRequest if err != nil { return nil, err } - return &user.ListIDPLinksResponse{ + return connect.NewResponse(&user.ListIDPLinksResponse{ Result: IDPLinksToPb(res.Links), Details: object.ToListDetails(res.SearchResponse), - }, nil + }), nil } func ListLinkedIDPsRequestToQuery(req *user.ListIDPLinksRequest) (*query.IDPUserLinksSearchQuery, error) { @@ -72,14 +74,14 @@ func IDPLinkToPb(link *query.IDPUserLink) *user.IDPLink { } } -func (s *Server) RemoveIDPLink(ctx context.Context, req *user.RemoveIDPLinkRequest) (*user.RemoveIDPLinkResponse, error) { - objectDetails, err := s.command.RemoveUserIDPLink(ctx, RemoveIDPLinkRequestToDomain(ctx, req)) +func (s *Server) RemoveIDPLink(ctx context.Context, req *connect.Request[user.RemoveIDPLinkRequest]) (*connect.Response[user.RemoveIDPLinkResponse], error) { + objectDetails, err := s.command.RemoveUserIDPLink(ctx, RemoveIDPLinkRequestToDomain(ctx, req.Msg)) if err != nil { return nil, err } - return &user.RemoveIDPLinkResponse{ + return connect.NewResponse(&user.RemoveIDPLinkResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } func RemoveIDPLinkRequestToDomain(ctx context.Context, req *user.RemoveIDPLinkRequest) *domain.UserIDPLink { diff --git a/internal/api/grpc/user/v2/integration_test/email_test.go b/internal/api/grpc/user/v2/integration_test/email_test.go index ad68ef5c5a..87f8a04dd8 100644 --- a/internal/api/grpc/user/v2/integration_test/email_test.go +++ b/internal/api/grpc/user/v2/integration_test/email_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" - + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" diff --git a/internal/api/grpc/user/v2/integration_test/key_test.go b/internal/api/grpc/user/v2/integration_test/key_test.go index e85903b2cb..bb4f8657fa 100644 --- a/internal/api/grpc/user/v2/integration_test/key_test.go +++ b/internal/api/grpc/user/v2/integration_test/key_test.go @@ -22,7 +22,7 @@ import ( ) func TestServer_AddKey(t *testing.T) { - resp := Instance.CreateUserTypeMachine(IamCTX) + resp := Instance.CreateUserTypeMachine(IamCTX, Instance.DefaultOrg.Id) userId := resp.GetId() expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) type args struct { @@ -108,7 +108,7 @@ abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ExpirationDate: expirationDate, }, func(request *user.AddKeyRequest) error { - resp := Instance.CreateUserTypeHuman(IamCTX) + resp := Instance.CreateUserTypeHuman(IamCTX, gofakeit.Email()) request.UserId = resp.Id return nil }, @@ -220,7 +220,7 @@ func TestServer_AddKey_Permission(t *testing.T) { } func TestServer_RemoveKey(t *testing.T) { - resp := Instance.CreateUserTypeMachine(IamCTX) + resp := Instance.CreateUserTypeMachine(IamCTX, Instance.DefaultOrg.Id) userId := resp.GetId() expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) type args struct { @@ -388,7 +388,7 @@ func TestServer_ListKeys(t *testing.T) { }) require.NoError(t, err) otherOrgUserId := otherOrgUser.GetId() - otherUserId := Instance.CreateUserTypeMachine(SystemCTX).GetId() + otherUserId := Instance.CreateUserTypeMachine(SystemCTX, Instance.DefaultOrg.Id).GetId() onlySinceTestStartFilter := &user.KeysSearchFilter{Filter: &user.KeysSearchFilter_CreatedDateFilter{CreatedDateFilter: &filter.TimestampFilter{ Timestamp: timestamppb.Now(), Method: filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS, diff --git a/internal/api/grpc/user/v2/integration_test/metadata_test.go b/internal/api/grpc/user/v2/integration_test/metadata_test.go new file mode 100644 index 0000000000..c1e3bced0a --- /dev/null +++ b/internal/api/grpc/user/v2/integration_test/metadata_test.go @@ -0,0 +1,403 @@ +//go:build integration + +package user_test + +import ( + "context" + "encoding/base64" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + metadata "github.com/zitadel/zitadel/pkg/grpc/metadata/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_SetUserMetadata(t *testing.T) { + iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + tests := []struct { + name string + ctx context.Context + dep func(request *user.SetUserMetadataRequest) + req *user.SetUserMetadataRequest + setDate bool + wantErr bool + }{ + { + name: "missing permission", + ctx: Instance.WithAuthorization(context.Background(), integration.UserTypeNoPermission), + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(CTX, gofakeit.Email()).GetId() + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}}, + }, + wantErr: true, + }, + { + name: "set user metadata", + ctx: iamOwnerCTX, + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(CTX, gofakeit.Email()).GetId() + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}}, + }, + setDate: true, + }, + { + name: "set user metadata, multiple", + ctx: iamOwnerCTX, + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(CTX, gofakeit.Email()).GetId() + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, + {Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))}, + }, + }, + setDate: true, + }, + { + name: "set user metadata on non existent user", + ctx: iamOwnerCTX, + req: &user.SetUserMetadataRequest{ + UserId: "notexisting", + Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}}, + }, + wantErr: true, + }, + { + name: "update user metadata", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + Instance.SetUserMetadata(iamOwnerCTX, req.UserId, "key1", "value1") + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}}, + }, + setDate: true, + }, + { + name: "update user metadata with same value", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + Instance.SetUserMetadata(iamOwnerCTX, req.UserId, "key1", "value1") + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}}, + }, + setDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + if tt.dep != nil { + tt.dep(tt.req) + } + got, err := Client.SetUserMetadata(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + assertSetUserMetadataResponse(t, creationDate, changeDate, tt.setDate, got) + }) + } +} + +func assertSetUserMetadataResponse(t *testing.T, creationDate, changeDate time.Time, expectedSetDat bool, actualResp *user.SetUserMetadataResponse) { + if expectedSetDat { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.SetDate) + } +} + +func TestServer_ListUserMetadata(t *testing.T) { + iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + type args struct { + ctx context.Context + dep func(context.Context, *user.ListUserMetadataRequest, *user.ListUserMetadataResponse) + req *user.ListUserMetadataRequest + } + + tests := []struct { + name string + args args + want *user.ListUserMetadataResponse + wantErr bool + }{ + { + name: "missing permission", + args: args{ + ctx: Instance.WithAuthorization(context.Background(), integration.UserTypeNoPermission), + dep: func(ctx context.Context, request *user.ListUserMetadataRequest, response *user.ListUserMetadataResponse) { + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + Instance.SetUserMetadata(iamOwnerCTX, userID, "key1", "value1") + }, + req: &user.ListUserMetadataRequest{}, + }, + wantErr: true, + }, + { + name: "list request", + args: args{ + ctx: iamOwnerCTX, + dep: func(ctx context.Context, request *user.ListUserMetadataRequest, response *user.ListUserMetadataResponse) { + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + metadataResp := Instance.SetUserMetadata(iamOwnerCTX, userID, "key1", "value1") + + response.Metadata[0] = &metadata.Metadata{ + CreationDate: metadataResp.GetSetDate(), + ChangeDate: metadataResp.GetSetDate(), + Key: "key1", + Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1"))), + } + }, + req: &user.ListUserMetadataRequest{}, + }, + want: &user.ListUserMetadataResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Metadata: []*metadata.Metadata{ + {}, + }, + }, + }, + { + name: "list request single key", + args: args{ + ctx: iamOwnerCTX, + dep: func(ctx context.Context, request *user.ListUserMetadataRequest, response *user.ListUserMetadataResponse) { + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + key := "key1" + response.Metadata[0] = setUserMetadata(iamOwnerCTX, userID, key, "value1") + Instance.SetUserMetadata(iamOwnerCTX, userID, "key2", "value2") + Instance.SetUserMetadata(iamOwnerCTX, userID, "key3", "value3") + request.Filters[0] = &metadata.MetadataSearchFilter{ + Filter: &metadata.MetadataSearchFilter_KeyFilter{KeyFilter: &metadata.MetadataKeyFilter{Key: key}}, + } + }, + req: &user.ListUserMetadataRequest{ + Filters: []*metadata.MetadataSearchFilter{{}}, + }, + }, + want: &user.ListUserMetadataResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Metadata: []*metadata.Metadata{ + {}, + }, + }, + }, + { + name: "list multiple keys", + args: args{ + ctx: iamOwnerCTX, + dep: func(ctx context.Context, request *user.ListUserMetadataRequest, response *user.ListUserMetadataResponse) { + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + + response.Metadata[2] = setUserMetadata(iamOwnerCTX, userID, "key1", "value1") + response.Metadata[1] = setUserMetadata(iamOwnerCTX, userID, "key2", "value2") + response.Metadata[0] = setUserMetadata(iamOwnerCTX, userID, "key3", "value3") + }, + req: &user.ListUserMetadataRequest{}, + }, + want: &user.ListUserMetadataResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Metadata: []*metadata.Metadata{ + {}, {}, {}, + }, + }, + }, + } + 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(iamOwnerCTX, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := Instance.Client.UserV2.ListUserMetadata(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Metadata, len(tt.want.Metadata)) { + assert.EqualExportedValues(ttt, got.Metadata, tt.want.Metadata) + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func setUserMetadata(ctx context.Context, userID, key, value string) *metadata.Metadata { + metadataResp := Instance.SetUserMetadata(ctx, userID, key, value) + return &metadata.Metadata{ + CreationDate: metadataResp.GetSetDate(), + ChangeDate: metadataResp.GetSetDate(), + Key: key, + Value: []byte(base64.StdEncoding.EncodeToString([]byte(value))), + } +} + +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_DeleteUserMetadata(t *testing.T) { + iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + tests := []struct { + name string + ctx context.Context + prepare func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) + req *user.DeleteUserMetadataRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "empty id", + ctx: iamOwnerCTX, + req: &user.DeleteUserMetadataRequest{ + UserId: "", + }, + wantErr: true, + }, + { + name: "delete, user not existing", + ctx: iamOwnerCTX, + req: &user.DeleteUserMetadataRequest{ + UserId: "notexisting", + }, + wantErr: true, + }, + { + name: "delete", + ctx: iamOwnerCTX, + prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + key := "key1" + Instance.SetUserMetadata(iamOwnerCTX, userID, key, "value1") + request.Keys = []string{key} + return creationDate, time.Time{} + }, + req: &user.DeleteUserMetadataRequest{}, + wantDeletionDate: true, + }, + { + name: "delete, empty list", + ctx: iamOwnerCTX, + prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + key := "key1" + Instance.SetUserMetadata(iamOwnerCTX, userID, key, "value1") + Instance.DeleteUserMetadata(iamOwnerCTX, userID, key) + return creationDate, time.Now().UTC() + }, + req: &user.DeleteUserMetadataRequest{}, + wantErr: true, + }, + { + name: "delete, already removed", + ctx: iamOwnerCTX, + prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + key := "key1" + Instance.SetUserMetadata(iamOwnerCTX, userID, key, "value1") + Instance.DeleteUserMetadata(iamOwnerCTX, userID, key) + request.Keys = []string{key} + return creationDate, time.Now().UTC() + }, + req: &user.DeleteUserMetadataRequest{}, + wantErr: true, + }, + { + name: "delete, multiple", + ctx: iamOwnerCTX, + prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + key1 := "key1" + Instance.SetUserMetadata(iamOwnerCTX, userID, key1, "value1") + key2 := "key2" + Instance.SetUserMetadata(iamOwnerCTX, userID, key2, "value1") + key3 := "key3" + Instance.SetUserMetadata(iamOwnerCTX, userID, key3, "value1") + request.Keys = []string{key1, key2, key3} + return creationDate, time.Time{} + }, + req: &user.DeleteUserMetadataRequest{}, + 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.UserV2.DeleteUserMetadata(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteProjectResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func assertDeleteProjectResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *user.DeleteUserMetadataResponse) { + if expectedDeletionDate { + if !deletionDate.IsZero() { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, deletionDate) + } else { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.DeletionDate) + } +} diff --git a/internal/api/grpc/user/v2/integration_test/pat_test.go b/internal/api/grpc/user/v2/integration_test/pat_test.go index ce974e0407..8ca6d80139 100644 --- a/internal/api/grpc/user/v2/integration_test/pat_test.go +++ b/internal/api/grpc/user/v2/integration_test/pat_test.go @@ -22,7 +22,7 @@ import ( ) func TestServer_AddPersonalAccessToken(t *testing.T) { - resp := Instance.CreateUserTypeMachine(IamCTX) + resp := Instance.CreateUserTypeMachine(IamCTX, Instance.DefaultOrg.Id) userId := resp.GetId() expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) type args struct { @@ -64,7 +64,7 @@ func TestServer_AddPersonalAccessToken(t *testing.T) { ExpirationDate: expirationDate, }, func(request *user.AddPersonalAccessTokenRequest) error { - resp := Instance.CreateUserTypeHuman(IamCTX) + resp := Instance.CreateUserTypeHuman(IamCTX, gofakeit.Email()) request.UserId = resp.Id return nil }, @@ -172,7 +172,7 @@ func TestServer_AddPersonalAccessToken_Permission(t *testing.T) { } func TestServer_RemovePersonalAccessToken(t *testing.T) { - resp := Instance.CreateUserTypeMachine(IamCTX) + resp := Instance.CreateUserTypeMachine(IamCTX, Instance.DefaultOrg.Id) userId := resp.GetId() expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) type args struct { @@ -339,7 +339,7 @@ func TestServer_ListPersonalAccessTokens(t *testing.T) { }) require.NoError(t, err) otherOrgUserId := otherOrgUser.GetId() - otherUserId := Instance.CreateUserTypeMachine(SystemCTX).GetId() + otherUserId := Instance.CreateUserTypeMachine(SystemCTX, Instance.DefaultOrg.Id).GetId() onlySinceTestStartFilter := &user.PersonalAccessTokensSearchFilter{Filter: &user.PersonalAccessTokensSearchFilter_CreatedDateFilter{CreatedDateFilter: &filter.TimestampFilter{ Timestamp: timestamppb.Now(), Method: filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS, diff --git a/internal/api/grpc/user/v2/integration_test/secret_test.go b/internal/api/grpc/user/v2/integration_test/secret_test.go index 8ff537b1fd..4296e8e599 100644 --- a/internal/api/grpc/user/v2/integration_test/secret_test.go +++ b/internal/api/grpc/user/v2/integration_test/secret_test.go @@ -43,7 +43,7 @@ func TestServer_AddSecret(t *testing.T) { CTX, &user.AddSecretRequest{}, func(request *user.AddSecretRequest) error { - resp := Instance.CreateUserTypeMachine(CTX) + resp := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id) request.UserId = resp.GetId() return nil }, @@ -55,7 +55,7 @@ func TestServer_AddSecret(t *testing.T) { CTX, &user.AddSecretRequest{}, func(request *user.AddSecretRequest) error { - resp := Instance.CreateUserTypeMachine(CTX) + resp := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id) request.UserId = resp.GetId() return nil }, @@ -67,7 +67,7 @@ func TestServer_AddSecret(t *testing.T) { CTX, &user.AddSecretRequest{}, func(request *user.AddSecretRequest) error { - resp := Instance.CreateUserTypeMachine(CTX) + resp := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id) request.UserId = resp.GetId() _, err := Client.AddSecret(CTX, &user.AddSecretRequest{ UserId: resp.GetId(), @@ -202,7 +202,7 @@ func TestServer_RemoveSecret(t *testing.T) { CTX, &user.RemoveSecretRequest{}, func(request *user.RemoveSecretRequest) error { - resp := Instance.CreateUserTypeMachine(CTX) + resp := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id) request.UserId = resp.GetId() return nil }, @@ -215,7 +215,7 @@ func TestServer_RemoveSecret(t *testing.T) { CTX, &user.RemoveSecretRequest{}, func(request *user.RemoveSecretRequest) error { - resp := Instance.CreateUserTypeMachine(CTX) + resp := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id) request.UserId = resp.GetId() _, err := Instance.Client.UserV2.AddSecret(CTX, &user.AddSecretRequest{ UserId: resp.GetId(), diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 4eee44ab44..452de6720c 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -4,6 +4,7 @@ package user_test import ( "context" + "encoding/base64" "fmt" "net/url" "os" @@ -1818,7 +1819,7 @@ func TestServer_DeleteUser(t *testing.T) { request.UserId = resp.GetUserId() Instance.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) Instance.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) - Instance.CreateOrgMembership(t, CTX, request.UserId) + Instance.CreateOrgMembership(t, CTX, Instance.DefaultOrg.Id, request.UserId) return CTX }, }, @@ -2057,7 +2058,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2081,7 +2082,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2105,7 +2106,9 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - postForm: true, + url: "http://localhost:8000/sso", + parametersExisting: []string{"RelayState", "SAMLRequest"}, + postForm: true, }, wantErr: false, }, @@ -2143,9 +2146,11 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } require.NoError(t, err) - if tt.want.url != "" { + if tt.want.url != "" && !tt.want.postForm { authUrl, err := url.Parse(got.GetAuthUrl()) require.NoError(t, err) + + assert.Equal(t, tt.want.url, authUrl.Scheme+"://"+authUrl.Host+authUrl.Path) require.Len(t, authUrl.Query(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) for _, existing := range tt.want.parametersExisting { @@ -2156,7 +2161,15 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } } if tt.want.postForm { - assert.NotEmpty(t, got.GetPostForm()) + assert.Equal(t, tt.want.url, got.GetFormData().GetUrl()) + + require.Len(t, got.GetFormData().GetFields(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) + for _, existing := range tt.want.parametersExisting { + assert.Contains(t, got.GetFormData().GetFields(), existing) + } + for key, equal := range tt.want.parametersEqual { + assert.Equal(t, got.GetFormData().GetFields()[key], equal) + } } integration.AssertDetails(t, &user.StartIdentityProviderIntentResponse{ Details: tt.want.details, @@ -3933,6 +3946,44 @@ func TestServer_CreateUser(t *testing.T) { } }, }, + { + name: "with metadata", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + Metadata: []*user.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, + {Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))}, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, { name: "with idp", testCase: func(runId string) testCase { @@ -4878,7 +4929,7 @@ func TestServer_UpdateUserTypeHuman(t *testing.T) { t.Run(tt.name, func(t *testing.T) { now := time.Now() runId := fmt.Sprint(now.UnixNano() + int64(i)) - userId := Instance.CreateUserTypeHuman(CTX).GetId() + userId := Instance.CreateUserTypeHuman(CTX, gofakeit.Email()).GetId() test := tt.testCase(runId, userId) got, err := Client.UpdateUser(test.args.ctx, test.args.req) if test.wantErr { @@ -4960,7 +5011,7 @@ func TestServer_UpdateUserTypeMachine(t *testing.T) { t.Run(tt.name, func(t *testing.T) { now := time.Now() runId := fmt.Sprint(now.UnixNano() + int64(i)) - userId := Instance.CreateUserTypeMachine(CTX).GetId() + userId := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id).GetId() test := tt.testCase(runId, userId) got, err := Client.UpdateUser(test.args.ctx, test.args.req) if test.wantErr { diff --git a/internal/api/grpc/user/v2/intent.go b/internal/api/grpc/user/v2/intent.go index 5514b6ef03..c26adba24d 100644 --- a/internal/api/grpc/user/v2/intent.go +++ b/internal/api/grpc/user/v2/intent.go @@ -6,6 +6,7 @@ import ( "errors" "time" + "connectrpc.com/connect" oidc_pkg "github.com/zitadel/oidc/v3/pkg/oidc" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -32,18 +33,18 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *user.StartIdentityProviderIntentRequest) (_ *user.StartIdentityProviderIntentResponse, err error) { - switch t := req.GetContent().(type) { +func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *connect.Request[user.StartIdentityProviderIntentRequest]) (_ *connect.Response[user.StartIdentityProviderIntentResponse], err error) { + switch t := req.Msg.GetContent().(type) { case *user.StartIdentityProviderIntentRequest_Urls: - return s.startIDPIntent(ctx, req.GetIdpId(), t.Urls) + return s.startIDPIntent(ctx, req.Msg.GetIdpId(), t.Urls) case *user.StartIdentityProviderIntentRequest_Ldap: - return s.startLDAPIntent(ctx, req.GetIdpId(), t.Ldap) + return s.startLDAPIntent(ctx, req.Msg.GetIdpId(), t.Ldap) default: return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-S2g21", "type oneOf %T in method StartIdentityProviderIntent not implemented", t) } } -func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderIntentResponse, error) { +func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*connect.Response[user.StartIdentityProviderIntentResponse], error) { state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID)) if err != nil { return nil, err @@ -52,22 +53,31 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re if err != nil { return nil, err } - content, redirect := session.GetAuth(ctx) - if redirect { - return &user.StartIdentityProviderIntentResponse{ - Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: content}, - }, nil + auth, err := session.GetAuth(ctx) + if err != nil { + return nil, err } - return &user.StartIdentityProviderIntentResponse{ - Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ - PostForm: []byte(content), - }, - }, nil + switch a := auth.(type) { + case *idp.RedirectAuth: + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: a.RedirectURL}, + }), nil + case *idp.FormAuth: + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_FormData{ + FormData: &user.FormData{ + Url: a.URL, + Fields: a.Fields, + }, + }, + }), nil + } + return nil, zerrors.ThrowInvalidArgumentf(nil, "USERv2-3g2j3", "type oneOf %T in method StartIdentityProviderIntent not implemented", auth) } -func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { +func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*connect.Response[user.StartIdentityProviderIntentResponse], error) { intentWriteModel, details, err := s.command.CreateIntent(ctx, "", idpID, "", "", authz.GetInstance(ctx).InstanceID(), nil) if err != nil { return nil, err @@ -83,7 +93,7 @@ func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredenti if err != nil { return nil, err } - return &user.StartIdentityProviderIntentResponse{ + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), NextStep: &user.StartIdentityProviderIntentResponse_IdpIntent{ IdpIntent: &user.IDPIntent{ @@ -92,7 +102,7 @@ func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredenti UserId: userID, }, }, - }, nil + }), nil } func (s *Server) checkLinkedExternalUser(ctx context.Context, idpID, externalUserID string) (string, error) { @@ -141,12 +151,12 @@ func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string return externalUser, userID, session, nil } -func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.RetrieveIdentityProviderIntentRequest) (_ *user.RetrieveIdentityProviderIntentResponse, err error) { - intent, err := s.command.GetIntentWriteModel(ctx, req.GetIdpIntentId(), "") +func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *connect.Request[user.RetrieveIdentityProviderIntentRequest]) (_ *connect.Response[user.RetrieveIdentityProviderIntentResponse], err error) { + intent, err := s.command.GetIntentWriteModel(ctx, req.Msg.GetIdpIntentId(), "") if err != nil { return nil, err } - if err := s.checkIntentToken(req.GetIdpIntentToken(), intent.AggregateID); err != nil { + if err := s.checkIntentToken(req.Msg.GetIdpIntentToken(), intent.AggregateID); err != nil { return nil, err } if intent.State != domain.IDPIntentStateSucceeded { @@ -194,7 +204,7 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R } idpIntent.AddHumanUser = idpUserToAddHumanUser(idpUser, idpIntent.IdpInformation.IdpId) } - return idpIntent, nil + return connect.NewResponse(idpIntent), nil } type rawUserMapper struct { diff --git a/internal/api/grpc/user/v2/key.go b/internal/api/grpc/user/v2/key.go index 59dab44248..021f4be388 100644 --- a/internal/api/grpc/user/v2/key.go +++ b/internal/api/grpc/user/v2/key.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" @@ -11,16 +12,16 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddKey(ctx context.Context, req *user.AddKeyRequest) (*user.AddKeyResponse, error) { +func (s *Server) AddKey(ctx context.Context, req *connect.Request[user.AddKeyRequest]) (*connect.Response[user.AddKeyResponse], error) { newMachineKey := &command.MachineKey{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.UserId, + AggregateID: req.Msg.GetUserId(), }, - ExpirationDate: req.GetExpirationDate().AsTime(), + ExpirationDate: req.Msg.GetExpirationDate().AsTime(), Type: domain.AuthNKeyTypeJSON, PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), } - newMachineKey.PublicKey = req.PublicKey + newMachineKey.PublicKey = req.Msg.GetPublicKey() pubkeySupplied := len(newMachineKey.PublicKey) > 0 details, err := s.command.AddUserMachineKey(ctx, newMachineKey) @@ -37,26 +38,26 @@ func (s *Server) AddKey(ctx context.Context, req *user.AddKeyRequest) (*user.Add return nil, err } } - return &user.AddKeyResponse{ + return connect.NewResponse(&user.AddKeyResponse{ KeyId: newMachineKey.KeyID, KeyContent: keyDetails, CreationDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) RemoveKey(ctx context.Context, req *user.RemoveKeyRequest) (*user.RemoveKeyResponse, error) { +func (s *Server) RemoveKey(ctx context.Context, req *connect.Request[user.RemoveKeyRequest]) (*connect.Response[user.RemoveKeyResponse], error) { machineKey := &command.MachineKey{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.UserId, + AggregateID: req.Msg.GetUserId(), }, PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), - KeyID: req.KeyId, + KeyID: req.Msg.GetKeyId(), } objectDetails, err := s.command.RemoveUserMachineKey(ctx, machineKey) if err != nil { return nil, err } - return &user.RemoveKeyResponse{ + return connect.NewResponse(&user.RemoveKeyResponse{ DeletionDate: timestamppb.New(objectDetails.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/key_query.go b/internal/api/grpc/user/v2/key_query.go index da4f47decf..e9466a791b 100644 --- a/internal/api/grpc/user/v2/key_query.go +++ b/internal/api/grpc/user/v2/key_query.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" @@ -12,13 +13,13 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) ListKeys(ctx context.Context, req *user.ListKeysRequest) (*user.ListKeysResponse, error) { - offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) +func (s *Server) ListKeys(ctx context.Context, req *connect.Request[user.ListKeysRequest]) (*connect.Response[user.ListKeysResponse], error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Msg.GetPagination()) if err != nil { return nil, err } - filters, err := keyFiltersToQueries(req.Filters) + filters, err := keyFiltersToQueries(req.Msg.GetFilters()) if err != nil { return nil, err } @@ -27,7 +28,7 @@ func (s *Server) ListKeys(ctx context.Context, req *user.ListKeysRequest) (*user Offset: offset, Limit: limit, Asc: asc, - SortingColumn: authnKeyFieldNameToSortingColumn(req.SortingColumn), + SortingColumn: authnKeyFieldNameToSortingColumn(req.Msg.SortingColumn), }, Queries: filters, } @@ -49,7 +50,7 @@ func (s *Server) ListKeys(ctx context.Context, req *user.ListKeysRequest) (*user ExpirationDate: timestamppb.New(key.Expiration), } } - return resp, nil + return connect.NewResponse(resp), nil } func keyFiltersToQueries(filters []*user.KeysSearchFilter) (_ []query.SearchQuery, err error) { diff --git a/internal/api/grpc/user/v2/machine.go b/internal/api/grpc/user/v2/machine.go index ad02b2289e..e5126b9019 100644 --- a/internal/api/grpc/user/v2/machine.go +++ b/internal/api/grpc/user/v2/machine.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" @@ -11,7 +12,7 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.CreateUserRequest_Machine, orgId, userName, userId string) (*user.CreateUserResponse, error) { +func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.CreateUserRequest_Machine, orgId, userName, userId string) (*connect.Response[user.CreateUserResponse], error) { cmd := &command.Machine{ Username: userName, Name: machinePb.Name, @@ -32,21 +33,21 @@ func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.Crea if err != nil { return nil, err } - return &user.CreateUserResponse{ + return connect.NewResponse(&user.CreateUserResponse{ Id: cmd.AggregateID, CreationDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) updateUserTypeMachine(ctx context.Context, machinePb *user.UpdateUserRequest_Machine, userId string, userName *string) (*user.UpdateUserResponse, error) { +func (s *Server) updateUserTypeMachine(ctx context.Context, machinePb *user.UpdateUserRequest_Machine, userId string, userName *string) (*connect.Response[user.UpdateUserResponse], error) { cmd := updateMachineUserToCommand(userId, userName, machinePb) err := s.command.ChangeUserMachine(ctx, cmd) if err != nil { return nil, err } - return &user.UpdateUserResponse{ + return connect.NewResponse(&user.UpdateUserResponse{ ChangeDate: timestamppb.New(cmd.Details.EventDate), - }, nil + }), nil } func updateMachineUserToCommand(userId string, userName *string, machine *user.UpdateUserRequest_Machine) *command.ChangeMachine { diff --git a/internal/api/grpc/user/v2/metadata.go b/internal/api/grpc/user/v2/metadata.go new file mode 100644 index 0000000000..338ce9fc45 --- /dev/null +++ b/internal/api/grpc/user/v2/metadata.go @@ -0,0 +1,80 @@ +package user + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/api/grpc/metadata/v2" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) ListUserMetadata(ctx context.Context, req *connect.Request[user.ListUserMetadataRequest]) (*connect.Response[user.ListUserMetadataResponse], error) { + metadataQueries, err := s.listUserMetadataRequestToModel(req.Msg) + if err != nil { + return nil, err + } + res, err := s.query.SearchUserMetadata(ctx, true, req.Msg.UserId, metadataQueries, s.checkPermission) + if err != nil { + return nil, err + } + return connect.NewResponse(&user.ListUserMetadataResponse{ + Metadata: metadata.UserMetadataListToPb(res.Metadata), + Pagination: filter.QueryToPaginationPb(metadataQueries.SearchRequest, res.SearchResponse), + }), nil +} + +func (s *Server) listUserMetadataRequestToModel(req *user.ListUserMetadataRequest) (*query.UserMetadataSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := metadata.UserMetadataFiltersToQuery(req.Filters) + if err != nil { + return nil, err + } + return &query.UserMetadataSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: query.UserMetadataCreationDateCol, + }, + Queries: queries, + }, nil +} + +func (s *Server) SetUserMetadata(ctx context.Context, req *connect.Request[user.SetUserMetadataRequest]) (*connect.Response[user.SetUserMetadataResponse], error) { + result, err := s.command.BulkSetUserMetadata(ctx, req.Msg.UserId, "", setUserMetadataToDomain(req.Msg)...) + if err != nil { + return nil, err + } + return connect.NewResponse(&user.SetUserMetadataResponse{ + SetDate: timestamppb.New(result.EventDate), + }), nil +} + +func setUserMetadataToDomain(req *user.SetUserMetadataRequest) []*domain.Metadata { + metadata := make([]*domain.Metadata, len(req.Metadata)) + for i, data := range req.Metadata { + metadata[i] = &domain.Metadata{ + Key: data.Key, + Value: data.Value, + } + } + return metadata +} + +func (s *Server) DeleteUserMetadata(ctx context.Context, req *connect.Request[user.DeleteUserMetadataRequest]) (*connect.Response[user.DeleteUserMetadataResponse], error) { + result, err := s.command.BulkRemoveUserMetadata(ctx, req.Msg.UserId, "", req.Msg.Keys...) + if err != nil { + return nil, err + } + return connect.NewResponse(&user.DeleteUserMetadataResponse{ + DeletionDate: timestamppb.New(result.EventDate), + }), nil +} diff --git a/internal/api/grpc/user/v2/otp.go b/internal/api/grpc/user/v2/otp.go index fd76cf2b93..2f04f438dd 100644 --- a/internal/api/grpc/user/v2/otp.go +++ b/internal/api/grpc/user/v2/otp.go @@ -3,39 +3,41 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) { - details, err := s.command.AddHumanOTPSMS(ctx, req.GetUserId(), "") +func (s *Server) AddOTPSMS(ctx context.Context, req *connect.Request[user.AddOTPSMSRequest]) (*connect.Response[user.AddOTPSMSResponse], error) { + details, err := s.command.AddHumanOTPSMS(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil + return connect.NewResponse(&user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}), nil } -func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) { - objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.GetUserId(), "") +func (s *Server) RemoveOTPSMS(ctx context.Context, req *connect.Request[user.RemoveOTPSMSRequest]) (*connect.Response[user.RemoveOTPSMSResponse], error) { + objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } -func (s *Server) AddOTPEmail(ctx context.Context, req *user.AddOTPEmailRequest) (*user.AddOTPEmailResponse, error) { - details, err := s.command.AddHumanOTPEmail(ctx, req.GetUserId(), "") +func (s *Server) AddOTPEmail(ctx context.Context, req *connect.Request[user.AddOTPEmailRequest]) (*connect.Response[user.AddOTPEmailResponse], error) { + details, err := s.command.AddHumanOTPEmail(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}, nil + return connect.NewResponse(&user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}), nil } -func (s *Server) RemoveOTPEmail(ctx context.Context, req *user.RemoveOTPEmailRequest) (*user.RemoveOTPEmailResponse, error) { - objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.GetUserId(), "") +func (s *Server) RemoveOTPEmail(ctx context.Context, req *connect.Request[user.RemoveOTPEmailRequest]) (*connect.Response[user.RemoveOTPEmailResponse], error) { + objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } diff --git a/internal/api/grpc/user/v2/passkey.go b/internal/api/grpc/user/v2/passkey.go index 145c1e5716..90c6d72d13 100644 --- a/internal/api/grpc/user/v2/passkey.go +++ b/internal/api/grpc/user/v2/passkey.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/structpb" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" @@ -13,17 +14,17 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) { +func (s *Server) RegisterPasskey(ctx context.Context, req *connect.Request[user.RegisterPasskeyRequest]) (resp *connect.Response[user.RegisterPasskeyResponse], err error) { var ( - authenticator = passkeyAuthenticatorToDomain(req.GetAuthenticator()) + authenticator = passkeyAuthenticatorToDomain(req.Msg.GetAuthenticator()) ) - if code := req.GetCode(); code != nil { + if code := req.Msg.GetCode(); code != nil { return passkeyRegistrationDetailsToPb( - s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), "", authenticator, code.Id, code.Code, req.GetDomain(), s.userCodeAlg), + s.command.RegisterUserPasskeyWithCode(ctx, req.Msg.GetUserId(), "", authenticator, code.Id, code.Code, req.Msg.GetDomain(), s.userCodeAlg), ) } return passkeyRegistrationDetailsToPb( - s.command.RegisterUserPasskey(ctx, req.GetUserId(), "", req.GetDomain(), authenticator), + s.command.RegisterUserPasskey(ctx, req.Msg.GetUserId(), "", req.Msg.GetDomain(), authenticator), ) } @@ -51,86 +52,86 @@ func webAuthNRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails return object.DomainToDetailsPb(details.ObjectDetails), options, nil } -func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) { +func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*connect.Response[user.RegisterPasskeyResponse], error) { objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) if err != nil { return nil, err } - return &user.RegisterPasskeyResponse{ + return connect.NewResponse(&user.RegisterPasskeyResponse{ Details: objectDetails, PasskeyId: details.ID, PublicKeyCredentialCreationOptions: options, - }, nil + }), nil } -func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) { - pkc, err := req.GetPublicKeyCredential().MarshalJSON() +func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *connect.Request[user.VerifyPasskeyRegistrationRequest]) (*connect.Response[user.VerifyPasskeyRegistrationResponse], error) { + pkc, err := req.Msg.GetPublicKeyCredential().MarshalJSON() if err != nil { return nil, zerrors.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal") } - objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), "", req.GetPasskeyName(), "", pkc) + objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.Msg.GetUserId(), "", req.Msg.GetPasskeyName(), "", pkc) if err != nil { return nil, err } - return &user.VerifyPasskeyRegistrationResponse{ + return connect.NewResponse(&user.VerifyPasskeyRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *user.CreatePasskeyRegistrationLinkRequest) (resp *user.CreatePasskeyRegistrationLinkResponse, err error) { - switch medium := req.Medium.(type) { +func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *connect.Request[user.CreatePasskeyRegistrationLinkRequest]) (resp *connect.Response[user.CreatePasskeyRegistrationLinkResponse], err error) { + switch medium := req.Msg.Medium.(type) { case nil: return passkeyDetailsToPb( - s.command.AddUserPasskeyCode(ctx, req.GetUserId(), "", s.userCodeAlg), + s.command.AddUserPasskeyCode(ctx, req.Msg.GetUserId(), "", s.userCodeAlg), ) case *user.CreatePasskeyRegistrationLinkRequest_SendLink: return passkeyDetailsToPb( - s.command.AddUserPasskeyCodeURLTemplate(ctx, req.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), + s.command.AddUserPasskeyCodeURLTemplate(ctx, req.Msg.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), ) case *user.CreatePasskeyRegistrationLinkRequest_ReturnCode: return passkeyCodeDetailsToPb( - s.command.AddUserPasskeyCodeReturn(ctx, req.GetUserId(), "", s.userCodeAlg), + s.command.AddUserPasskeyCodeReturn(ctx, req.Msg.GetUserId(), "", s.userCodeAlg), ) default: return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-gaD8y", "verification oneOf %T in method CreatePasskeyRegistrationLink not implemented", medium) } } -func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { +func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*connect.Response[user.CreatePasskeyRegistrationLinkResponse], error) { if err != nil { return nil, err } - return &user.CreatePasskeyRegistrationLinkResponse{ + return connect.NewResponse(&user.CreatePasskeyRegistrationLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { +func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*connect.Response[user.CreatePasskeyRegistrationLinkResponse], error) { if err != nil { return nil, err } - return &user.CreatePasskeyRegistrationLinkResponse{ + return connect.NewResponse(&user.CreatePasskeyRegistrationLinkResponse{ Details: object.DomainToDetailsPb(details.ObjectDetails), Code: &user.PasskeyRegistrationCode{ Id: details.CodeID, Code: details.Code, }, - }, nil + }), nil } -func (s *Server) RemovePasskey(ctx context.Context, req *user.RemovePasskeyRequest) (*user.RemovePasskeyResponse, error) { - objectDetails, err := s.command.HumanRemovePasswordless(ctx, req.GetUserId(), req.GetPasskeyId(), "") +func (s *Server) RemovePasskey(ctx context.Context, req *connect.Request[user.RemovePasskeyRequest]) (*connect.Response[user.RemovePasskeyResponse], error) { + objectDetails, err := s.command.HumanRemovePasswordless(ctx, req.Msg.GetUserId(), req.Msg.GetPasskeyId(), "") if err != nil { return nil, err } - return &user.RemovePasskeyResponse{ + return connect.NewResponse(&user.RemovePasskeyResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) ListPasskeys(ctx context.Context, req *user.ListPasskeysRequest) (*user.ListPasskeysResponse, error) { +func (s *Server) ListPasskeys(ctx context.Context, req *connect.Request[user.ListPasskeysRequest]) (*connect.Response[user.ListPasskeysResponse], error) { query := new(query.UserAuthMethodSearchQueries) - err := query.AppendUserIDQuery(req.UserId) + err := query.AppendUserIDQuery(req.Msg.UserId) if err != nil { return nil, err } @@ -146,10 +147,10 @@ func (s *Server) ListPasskeys(ctx context.Context, req *user.ListPasskeysRequest if err != nil { return nil, err } - return &user.ListPasskeysResponse{ + return connect.NewResponse(&user.ListPasskeysResponse{ Details: object.ToListDetails(authMethods.SearchResponse), Result: authMethodsToPasskeyPb(authMethods), - }, nil + }), nil } func authMethodsToPasskeyPb(methods *query.AuthMethods) []*user.Passkey { diff --git a/internal/api/grpc/user/v2/passkey_test.go b/internal/api/grpc/user/v2/passkey_test.go index 9263012b98..6429dd7ce6 100644 --- a/internal/api/grpc/user/v2/passkey_test.go +++ b/internal/api/grpc/user/v2/passkey_test.go @@ -123,11 +123,11 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) } if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } @@ -181,7 +181,9 @@ func Test_passkeyDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) + if tt.want != nil { + assert.Equal(t, tt.want, got.Msg) + } }) } } @@ -242,9 +244,9 @@ func Test_passkeyCodeDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyCodeDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + assert.Equal(t, tt.want, got.Msg) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } diff --git a/internal/api/grpc/user/v2/password.go b/internal/api/grpc/user/v2/password.go index 55cf225c4b..a256a00355 100644 --- a/internal/api/grpc/user/v2/password.go +++ b/internal/api/grpc/user/v2/password.go @@ -3,23 +3,25 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) { +func (s *Server) PasswordReset(ctx context.Context, req *connect.Request[user.PasswordResetRequest]) (_ *connect.Response[user.PasswordResetResponse], err error) { var details *domain.ObjectDetails var code *string - switch m := req.GetMedium().(type) { + switch m := req.Msg.GetMedium().(type) { case *user.PasswordResetRequest_SendLink: - details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) + details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.Msg.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) case *user.PasswordResetRequest_ReturnCode: - details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.GetUserId()) + details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.Msg.GetUserId()) case nil: - details, code, err = s.command.RequestPasswordReset(ctx, req.GetUserId()) + details, code, err = s.command.RequestPasswordReset(ctx, req.Msg.GetUserId()) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-SDeeg", "verification oneOf %T in method RequestPasswordReset not implemented", m) } @@ -27,10 +29,10 @@ func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetReque return nil, err } - return &user.PasswordResetResponse{ + return connect.NewResponse(&user.PasswordResetResponse{ Details: object.DomainToDetailsPb(details), VerificationCode: code, - }, nil + }), nil } func notificationTypeToDomain(notificationType user.NotificationType) domain.NotificationType { @@ -46,16 +48,16 @@ func notificationTypeToDomain(notificationType user.NotificationType) domain.Not } } -func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) (_ *user.SetPasswordResponse, err error) { +func (s *Server) SetPassword(ctx context.Context, req *connect.Request[user.SetPasswordRequest]) (_ *connect.Response[user.SetPasswordResponse], err error) { var details *domain.ObjectDetails - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetPasswordRequest_CurrentPassword: - details, err = s.command.ChangePassword(ctx, "", req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + details, err = s.command.ChangePassword(ctx, "", req.Msg.GetUserId(), v.CurrentPassword, req.Msg.GetNewPassword().GetPassword(), "", req.Msg.GetNewPassword().GetChangeRequired()) case *user.SetPasswordRequest_VerificationCode: - details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.Msg.GetUserId(), v.VerificationCode, req.Msg.GetNewPassword().GetPassword(), "", req.Msg.GetNewPassword().GetChangeRequired()) case nil: - details, err = s.command.SetPassword(ctx, "", req.GetUserId(), req.GetNewPassword().GetPassword(), req.GetNewPassword().GetChangeRequired()) + details, err = s.command.SetPassword(ctx, "", req.Msg.GetUserId(), req.Msg.GetNewPassword().GetPassword(), req.Msg.GetNewPassword().GetChangeRequired()) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-SFdf2", "verification oneOf %T in method SetPasswordRequest not implemented", v) } @@ -63,7 +65,7 @@ func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) return nil, err } - return &user.SetPasswordResponse{ + return connect.NewResponse(&user.SetPasswordResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/pat.go b/internal/api/grpc/user/v2/pat.go index 54f6e99367..0c90eeaebd 100644 --- a/internal/api/grpc/user/v2/pat.go +++ b/internal/api/grpc/user/v2/pat.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "github.com/zitadel/oidc/v3/pkg/oidc" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,13 +14,13 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddPersonalAccessToken(ctx context.Context, req *user.AddPersonalAccessTokenRequest) (*user.AddPersonalAccessTokenResponse, error) { +func (s *Server) AddPersonalAccessToken(ctx context.Context, req *connect.Request[user.AddPersonalAccessTokenRequest]) (*connect.Response[user.AddPersonalAccessTokenResponse], error) { newPat := &command.PersonalAccessToken{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.UserId, + AggregateID: req.Msg.GetUserId(), }, PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), - ExpirationDate: req.ExpirationDate.AsTime(), + ExpirationDate: req.Msg.GetExpirationDate().AsTime(), Scopes: []string{ oidc.ScopeOpenID, oidc.ScopeProfile, @@ -32,25 +33,25 @@ func (s *Server) AddPersonalAccessToken(ctx context.Context, req *user.AddPerson if err != nil { return nil, err } - return &user.AddPersonalAccessTokenResponse{ + return connect.NewResponse(&user.AddPersonalAccessTokenResponse{ CreationDate: timestamppb.New(details.EventDate), TokenId: newPat.TokenID, Token: newPat.Token, - }, nil + }), nil } -func (s *Server) RemovePersonalAccessToken(ctx context.Context, req *user.RemovePersonalAccessTokenRequest) (*user.RemovePersonalAccessTokenResponse, error) { +func (s *Server) RemovePersonalAccessToken(ctx context.Context, req *connect.Request[user.RemovePersonalAccessTokenRequest]) (*connect.Response[user.RemovePersonalAccessTokenResponse], error) { objectDetails, err := s.command.RemovePersonalAccessToken(ctx, &command.PersonalAccessToken{ - TokenID: req.TokenId, + TokenID: req.Msg.GetTokenId(), ObjectRoot: models.ObjectRoot{ - AggregateID: req.UserId, + AggregateID: req.Msg.GetUserId(), }, PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), }) if err != nil { return nil, err } - return &user.RemovePersonalAccessTokenResponse{ + return connect.NewResponse(&user.RemovePersonalAccessTokenResponse{ DeletionDate: timestamppb.New(objectDetails.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/pat_query.go b/internal/api/grpc/user/v2/pat_query.go index 6bbd44d511..64231c1d93 100644 --- a/internal/api/grpc/user/v2/pat_query.go +++ b/internal/api/grpc/user/v2/pat_query.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" @@ -12,12 +13,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPersonalAccessTokensRequest) (*user.ListPersonalAccessTokensResponse, error) { - offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) +func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *connect.Request[user.ListPersonalAccessTokensRequest]) (*connect.Response[user.ListPersonalAccessTokensResponse], error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Msg.GetPagination()) if err != nil { return nil, err } - filters, err := patFiltersToQueries(req.Filters) + filters, err := patFiltersToQueries(req.Msg.GetFilters()) if err != nil { return nil, err } @@ -26,7 +27,7 @@ func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPer Offset: offset, Limit: limit, Asc: asc, - SortingColumn: authnPersonalAccessTokenFieldNameToSortingColumn(req.SortingColumn), + SortingColumn: authnPersonalAccessTokenFieldNameToSortingColumn(req.Msg.SortingColumn), }, Queries: filters, } @@ -48,7 +49,7 @@ func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPer ExpirationDate: timestamppb.New(pat.Expiration), } } - return resp, nil + return connect.NewResponse(resp), nil } func patFiltersToQueries(filters []*user.PersonalAccessTokensSearchFilter) (_ []query.SearchQuery, err error) { diff --git a/internal/api/grpc/user/v2/phone.go b/internal/api/grpc/user/v2/phone.go index fdd5a140c1..4be616f7ea 100644 --- a/internal/api/grpc/user/v2/phone.go +++ b/internal/api/grpc/user/v2/phone.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) { +func (s *Server) SetPhone(ctx context.Context, req *connect.Request[user.SetPhoneRequest]) (resp *connect.Response[user.SetPhoneResponse], err error) { var phone *domain.Phone - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetPhoneRequest_SendCode: - phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhone(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) case *user.SetPhoneRequest_ReturnCode: - phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) case *user.SetPhoneRequest_IsVerified: - phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), req.GetPhone()) + phone, err = s.command.ChangeUserPhoneVerified(ctx, req.Msg.GetUserId(), req.Msg.GetPhone()) case nil: - phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhone(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetPhone not implemented", v) } @@ -30,42 +31,42 @@ func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp return nil, err } - return &user.SetPhoneResponse{ + return connect.NewResponse(&user.SetPhoneResponse{ Details: &object.Details{ Sequence: phone.Sequence, ChangeDate: timestamppb.New(phone.ChangeDate), ResourceOwner: phone.ResourceOwner, }, VerificationCode: phone.PlainCode, - }, nil + }), nil } -func (s *Server) RemovePhone(ctx context.Context, req *user.RemovePhoneRequest) (resp *user.RemovePhoneResponse, err error) { +func (s *Server) RemovePhone(ctx context.Context, req *connect.Request[user.RemovePhoneRequest]) (resp *connect.Response[user.RemovePhoneResponse], err error) { details, err := s.command.RemoveUserPhone(ctx, - req.GetUserId(), + req.Msg.GetUserId(), ) if err != nil { return nil, err } - return &user.RemovePhoneResponse{ + return connect.NewResponse(&user.RemovePhoneResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeRequest) (resp *user.ResendPhoneCodeResponse, err error) { +func (s *Server) ResendPhoneCode(ctx context.Context, req *connect.Request[user.ResendPhoneCodeRequest]) (resp *connect.Response[user.ResendPhoneCodeResponse], err error) { var phone *domain.Phone - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendPhoneCodeRequest_SendCode: - phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case *user.ResendPhoneCodeRequest_ReturnCode: - phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-ResendUserPhoneCode", "verification oneOf %T in method SetPhone not implemented", v) } @@ -73,30 +74,30 @@ func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeR return nil, err } - return &user.ResendPhoneCodeResponse{ + return connect.NewResponse(&user.ResendPhoneCodeResponse{ Details: &object.Details{ Sequence: phone.Sequence, ChangeDate: timestamppb.New(phone.ChangeDate), ResourceOwner: phone.ResourceOwner, }, VerificationCode: phone.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyPhone(ctx context.Context, req *user.VerifyPhoneRequest) (*user.VerifyPhoneResponse, error) { +func (s *Server) VerifyPhone(ctx context.Context, req *connect.Request[user.VerifyPhoneRequest]) (*connect.Response[user.VerifyPhoneResponse], error) { details, err := s.command.VerifyUserPhone(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyPhoneResponse{ + return connect.NewResponse(&user.VerifyPhoneResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/secret.go b/internal/api/grpc/user/v2/secret.go index 1d54e1dde8..acc7aef8cb 100644 --- a/internal/api/grpc/user/v2/secret.go +++ b/internal/api/grpc/user/v2/secret.go @@ -3,37 +3,38 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddSecret(ctx context.Context, req *user.AddSecretRequest) (*user.AddSecretResponse, error) { +func (s *Server) AddSecret(ctx context.Context, req *connect.Request[user.AddSecretRequest]) (*connect.Response[user.AddSecretResponse], error) { newSecret := &command.GenerateMachineSecret{ PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), } - details, err := s.command.GenerateMachineSecret(ctx, req.UserId, "", newSecret) + details, err := s.command.GenerateMachineSecret(ctx, req.Msg.GetUserId(), "", newSecret) if err != nil { return nil, err } - return &user.AddSecretResponse{ + return connect.NewResponse(&user.AddSecretResponse{ CreationDate: timestamppb.New(details.EventDate), ClientSecret: newSecret.ClientSecret, - }, nil + }), nil } -func (s *Server) RemoveSecret(ctx context.Context, req *user.RemoveSecretRequest) (*user.RemoveSecretResponse, error) { +func (s *Server) RemoveSecret(ctx context.Context, req *connect.Request[user.RemoveSecretRequest]) (*connect.Response[user.RemoveSecretResponse], error) { details, err := s.command.RemoveMachineSecret( ctx, - req.UserId, + req.Msg.GetUserId(), "", s.command.NewPermissionCheckUserWrite(ctx), ) if err != nil { return nil, err } - return &user.RemoveSecretResponse{ + return connect.NewResponse(&user.RemoveSecretResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/server.go b/internal/api/grpc/user/v2/server.go index e3c7e8011e..e89d0a7d60 100644 --- a/internal/api/grpc/user/v2/server.go +++ b/internal/api/grpc/user/v2/server.go @@ -2,8 +2,10 @@ package user import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -13,12 +15,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2/userconnect" ) -var _ user.UserServiceServer = (*Server)(nil) +var _ userconnect.UserServiceHandler = (*Server)(nil) type Server struct { - user.UnimplementedUserServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -35,9 +37,9 @@ type Server struct { type Config struct{} func CreateServer( - systemDefaults systemdefaults.SystemDefaults, command *command.Commands, query *query.Queries, + systemDefaults systemdefaults.SystemDefaults, userCodeAlg crypto.EncryptionAlgorithm, idpAlg crypto.EncryptionAlgorithm, idpCallback func(ctx context.Context) string, @@ -46,7 +48,6 @@ func CreateServer( checkPermission domain.PermissionCheck, ) *Server { return &Server{ - systemDefaults: systemDefaults, command: command, query: query, userCodeAlg: userCodeAlg, @@ -55,11 +56,16 @@ func CreateServer( samlRootURL: samlRootURL, assetAPIPrefix: assetAPIPrefix, checkPermission: checkPermission, + systemDefaults: systemDefaults, } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - user.RegisterUserServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return userconnect.NewUserServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return user.File_zitadel_user_v2_user_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/user/v2/totp.go b/internal/api/grpc/user/v2/totp.go index 9e2d028d72..51b615dac5 100644 --- a/internal/api/grpc/user/v2/totp.go +++ b/internal/api/grpc/user/v2/totp.go @@ -3,42 +3,44 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) RegisterTOTP(ctx context.Context, req *user.RegisterTOTPRequest) (*user.RegisterTOTPResponse, error) { +func (s *Server) RegisterTOTP(ctx context.Context, req *connect.Request[user.RegisterTOTPRequest]) (*connect.Response[user.RegisterTOTPResponse], error) { return totpDetailsToPb( - s.command.AddUserTOTP(ctx, req.GetUserId(), ""), + s.command.AddUserTOTP(ctx, req.Msg.GetUserId(), ""), ) } -func totpDetailsToPb(totp *domain.TOTP, err error) (*user.RegisterTOTPResponse, error) { +func totpDetailsToPb(totp *domain.TOTP, err error) (*connect.Response[user.RegisterTOTPResponse], error) { if err != nil { return nil, err } - return &user.RegisterTOTPResponse{ + return connect.NewResponse(&user.RegisterTOTPResponse{ Details: object.DomainToDetailsPb(totp.ObjectDetails), Uri: totp.URI, Secret: totp.Secret, - }, nil + }), nil } -func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *user.VerifyTOTPRegistrationRequest) (*user.VerifyTOTPRegistrationResponse, error) { - objectDetails, err := s.command.CheckUserTOTP(ctx, req.GetUserId(), req.GetCode(), "") +func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *connect.Request[user.VerifyTOTPRegistrationRequest]) (*connect.Response[user.VerifyTOTPRegistrationResponse], error) { + objectDetails, err := s.command.CheckUserTOTP(ctx, req.Msg.GetUserId(), req.Msg.GetCode(), "") if err != nil { return nil, err } - return &user.VerifyTOTPRegistrationResponse{ + return connect.NewResponse(&user.VerifyTOTPRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) RemoveTOTP(ctx context.Context, req *user.RemoveTOTPRequest) (*user.RemoveTOTPResponse, error) { - objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.GetUserId(), "") +func (s *Server) RemoveTOTP(ctx context.Context, req *connect.Request[user.RemoveTOTPRequest]) (*connect.Response[user.RemoveTOTPResponse], error) { + objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } diff --git a/internal/api/grpc/user/v2/totp_test.go b/internal/api/grpc/user/v2/totp_test.go index 27ce6fb469..259f5ab5c6 100644 --- a/internal/api/grpc/user/v2/totp_test.go +++ b/internal/api/grpc/user/v2/totp_test.go @@ -63,7 +63,7 @@ func Test_totpDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := totpDetailsToPb(tt.args.otp, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("RegisterTOTPResponse =\n%v\nwant\n%v", got, tt.want) } }) diff --git a/internal/api/grpc/user/v2/u2f.go b/internal/api/grpc/user/v2/u2f.go index 60c0f5ab07..bd12ea0dac 100644 --- a/internal/api/grpc/user/v2/u2f.go +++ b/internal/api/grpc/user/v2/u2f.go @@ -3,50 +3,52 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) { +func (s *Server) RegisterU2F(ctx context.Context, req *connect.Request[user.RegisterU2FRequest]) (*connect.Response[user.RegisterU2FResponse], error) { return u2fRegistrationDetailsToPb( - s.command.RegisterUserU2F(ctx, req.GetUserId(), "", req.GetDomain()), + s.command.RegisterUserU2F(ctx, req.Msg.GetUserId(), "", req.Msg.GetDomain()), ) } -func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterU2FResponse, error) { +func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*connect.Response[user.RegisterU2FResponse], error) { objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) if err != nil { return nil, err } - return &user.RegisterU2FResponse{ + return connect.NewResponse(&user.RegisterU2FResponse{ Details: objectDetails, U2FId: details.ID, PublicKeyCredentialCreationOptions: options, - }, nil + }), nil } -func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FRegistrationRequest) (*user.VerifyU2FRegistrationResponse, error) { - pkc, err := req.GetPublicKeyCredential().MarshalJSON() +func (s *Server) VerifyU2FRegistration(ctx context.Context, req *connect.Request[user.VerifyU2FRegistrationRequest]) (*connect.Response[user.VerifyU2FRegistrationResponse], error) { + pkc, err := req.Msg.GetPublicKeyCredential().MarshalJSON() if err != nil { return nil, zerrors.ThrowInternal(err, "USERv2-IeTh4", "Errors.Internal") } - objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.GetUserId(), "", req.GetTokenName(), "", pkc) + objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.Msg.GetUserId(), "", req.Msg.GetTokenName(), "", pkc) if err != nil { return nil, err } - return &user.VerifyU2FRegistrationResponse{ + return connect.NewResponse(&user.VerifyU2FRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) RemoveU2F(ctx context.Context, req *user.RemoveU2FRequest) (*user.RemoveU2FResponse, error) { - objectDetails, err := s.command.HumanRemoveU2F(ctx, req.GetUserId(), req.GetU2FId(), "") +func (s *Server) RemoveU2F(ctx context.Context, req *connect.Request[user.RemoveU2FRequest]) (*connect.Response[user.RemoveU2FResponse], error) { + objectDetails, err := s.command.HumanRemoveU2F(ctx, req.Msg.GetUserId(), req.Msg.GetU2FId(), "") if err != nil { return nil, err } - return &user.RemoveU2FResponse{ + return connect.NewResponse(&user.RemoveU2FResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/u2f_test.go b/internal/api/grpc/user/v2/u2f_test.go index fae3ba1cdb..f6798a6f89 100644 --- a/internal/api/grpc/user/v2/u2f_test.go +++ b/internal/api/grpc/user/v2/u2f_test.go @@ -92,11 +92,11 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := u2fRegistrationDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) } if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 6b4b2da75b..3eeda8da5f 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -4,6 +4,7 @@ import ( "context" "io" + "connectrpc.com/connect" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" @@ -15,8 +16,8 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { - human, err := AddUserRequestToAddHuman(req) +func (s *Server) AddHumanUser(ctx context.Context, req *connect.Request[user.AddHumanUserRequest]) (_ *connect.Response[user.AddHumanUserResponse], err error) { + human, err := AddUserRequestToAddHuman(req.Msg) if err != nil { return nil, err } @@ -24,12 +25,12 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest if err = s.command.AddUserHuman(ctx, orgID, human, false, s.userCodeAlg); err != nil { return nil, err } - return &user.AddHumanUserResponse{ + return connect.NewResponse(&user.AddHumanUserResponse{ UserId: human.ID, Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) { @@ -117,8 +118,8 @@ func genderToDomain(gender user.Gender) domain.Gender { } } -func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) { - human, err := updateHumanUserRequestToChangeHuman(req) +func (s *Server) UpdateHumanUser(ctx context.Context, req *connect.Request[user.UpdateHumanUserRequest]) (_ *connect.Response[user.UpdateHumanUserResponse], err error) { + human, err := updateHumanUserRequestToChangeHuman(req.Msg) if err != nil { return nil, err } @@ -126,51 +127,51 @@ func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserR if err != nil { return nil, err } - return &user.UpdateHumanUserResponse{ + return connect.NewResponse(&user.UpdateHumanUserResponse{ Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } -func (s *Server) LockUser(ctx context.Context, req *user.LockUserRequest) (_ *user.LockUserResponse, err error) { - details, err := s.command.LockUserV2(ctx, req.UserId) +func (s *Server) LockUser(ctx context.Context, req *connect.Request[user.LockUserRequest]) (_ *connect.Response[user.LockUserResponse], err error) { + details, err := s.command.LockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.LockUserResponse{ + return connect.NewResponse(&user.LockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) UnlockUser(ctx context.Context, req *user.UnlockUserRequest) (_ *user.UnlockUserResponse, err error) { - details, err := s.command.UnlockUserV2(ctx, req.UserId) +func (s *Server) UnlockUser(ctx context.Context, req *connect.Request[user.UnlockUserRequest]) (_ *connect.Response[user.UnlockUserResponse], err error) { + details, err := s.command.UnlockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.UnlockUserResponse{ + return connect.NewResponse(&user.UnlockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { - details, err := s.command.DeactivateUserV2(ctx, req.UserId) +func (s *Server) DeactivateUser(ctx context.Context, req *connect.Request[user.DeactivateUserRequest]) (_ *connect.Response[user.DeactivateUserResponse], err error) { + details, err := s.command.DeactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.DeactivateUserResponse{ + return connect.NewResponse(&user.DeactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ReactivateUser(ctx context.Context, req *user.ReactivateUserRequest) (_ *user.ReactivateUserResponse, err error) { - details, err := s.command.ReactivateUserV2(ctx, req.UserId) +func (s *Server) ReactivateUser(ctx context.Context, req *connect.Request[user.ReactivateUserRequest]) (_ *connect.Response[user.ReactivateUserResponse], err error) { + details, err := s.command.ReactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.ReactivateUserResponse{ + return connect.NewResponse(&user.ReactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { @@ -182,18 +183,18 @@ func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { return &pVal } -func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { - memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) +func (s *Server) DeleteUser(ctx context.Context, req *connect.Request[user.DeleteUserRequest]) (_ *connect.Response[user.DeleteUserResponse], err error) { + memberships, grants, err := s.removeUserDependencies(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - details, err := s.command.RemoveUserV2(ctx, req.UserId, "", memberships, grants...) + details, err := s.command.RemoveUserV2(ctx, req.Msg.GetUserId(), "", memberships, grants...) if err != nil { return nil, err } - return &user.DeleteUserResponse{ + return connect.NewResponse(&user.DeleteUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { @@ -203,7 +204,7 @@ func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]* } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, - }, true) + }, true, nil) if err != nil { return nil, nil, err } @@ -268,35 +269,35 @@ func userGrantsToIDs(userGrants []*query.UserGrant) []string { return converted } -func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.ListAuthenticationMethodTypesRequest) (*user.ListAuthenticationMethodTypesResponse, error) { - authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.GetUserId(), true, req.GetDomainQuery().GetIncludeWithoutDomain(), req.GetDomainQuery().GetDomain()) +func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *connect.Request[user.ListAuthenticationMethodTypesRequest]) (*connect.Response[user.ListAuthenticationMethodTypesResponse], error) { + authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.Msg.GetUserId(), true, req.Msg.GetDomainQuery().GetIncludeWithoutDomain(), req.Msg.GetDomainQuery().GetDomain()) if err != nil { return nil, err } - return &user.ListAuthenticationMethodTypesResponse{ + return connect.NewResponse(&user.ListAuthenticationMethodTypesResponse{ Details: object.ToListDetails(authMethods.SearchResponse), AuthMethodTypes: authMethodTypesToPb(authMethods.AuthMethodTypes), - }, nil + }), nil } -func (s *Server) ListAuthenticationFactors(ctx context.Context, req *user.ListAuthenticationFactorsRequest) (*user.ListAuthenticationFactorsResponse, error) { +func (s *Server) ListAuthenticationFactors(ctx context.Context, req *connect.Request[user.ListAuthenticationFactorsRequest]) (*connect.Response[user.ListAuthenticationFactorsResponse], error) { query := new(query.UserAuthMethodSearchQueries) - if err := query.AppendUserIDQuery(req.UserId); err != nil { + if err := query.AppendUserIDQuery(req.Msg.GetUserId()); err != nil { return nil, err } authMethodsType := []domain.UserAuthMethodType{domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail} - if len(req.GetAuthFactors()) > 0 { - authMethodsType = object.AuthFactorsToPb(req.GetAuthFactors()) + if len(req.Msg.GetAuthFactors()) > 0 { + authMethodsType = object.AuthFactorsToPb(req.Msg.GetAuthFactors()) } if err := query.AppendAuthMethodsQuery(authMethodsType...); err != nil { return nil, err } states := []domain.MFAState{domain.MFAStateReady} - if len(req.GetStates()) > 0 { - states = object.AuthFactorStatesToPb(req.GetStates()) + if len(req.Msg.GetStates()) > 0 { + states = object.AuthFactorStatesToPb(req.Msg.GetStates()) } if err := query.AppendStatesQuery(states...); err != nil { return nil, err @@ -307,9 +308,9 @@ func (s *Server) ListAuthenticationFactors(ctx context.Context, req *user.ListAu return nil, err } - return &user.ListAuthenticationFactorsResponse{ + return connect.NewResponse(&user.ListAuthenticationFactorsResponse{ Result: object.AuthMethodsToPb(authMethods), - }, nil + }), nil } func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType { @@ -343,8 +344,8 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio } } -func (s *Server) CreateInviteCode(ctx context.Context, req *user.CreateInviteCodeRequest) (*user.CreateInviteCodeResponse, error) { - invite, err := createInviteCodeRequestToCommand(req) +func (s *Server) CreateInviteCode(ctx context.Context, req *connect.Request[user.CreateInviteCodeRequest]) (*connect.Response[user.CreateInviteCodeResponse], error) { + invite, err := createInviteCodeRequestToCommand(req.Msg) if err != nil { return nil, err } @@ -352,30 +353,30 @@ func (s *Server) CreateInviteCode(ctx context.Context, req *user.CreateInviteCod if err != nil { return nil, err } - return &user.CreateInviteCodeResponse{ + return connect.NewResponse(&user.CreateInviteCodeResponse{ Details: object.DomainToDetailsPb(details), InviteCode: code, - }, nil + }), nil } -func (s *Server) ResendInviteCode(ctx context.Context, req *user.ResendInviteCodeRequest) (*user.ResendInviteCodeResponse, error) { - details, err := s.command.ResendInviteCode(ctx, req.GetUserId(), "", "") +func (s *Server) ResendInviteCode(ctx context.Context, req *connect.Request[user.ResendInviteCodeRequest]) (*connect.Response[user.ResendInviteCodeResponse], error) { + details, err := s.command.ResendInviteCode(ctx, req.Msg.GetUserId(), "", "") if err != nil { return nil, err } - return &user.ResendInviteCodeResponse{ + return connect.NewResponse(&user.ResendInviteCodeResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) VerifyInviteCode(ctx context.Context, req *user.VerifyInviteCodeRequest) (*user.VerifyInviteCodeResponse, error) { - details, err := s.command.VerifyInviteCode(ctx, req.GetUserId(), req.GetVerificationCode()) +func (s *Server) VerifyInviteCode(ctx context.Context, req *connect.Request[user.VerifyInviteCodeRequest]) (*connect.Response[user.VerifyInviteCodeResponse], error) { + details, err := s.command.VerifyInviteCode(ctx, req.Msg.GetUserId(), req.Msg.GetVerificationCode()) if err != nil { return nil, err } - return &user.VerifyInviteCodeResponse{ + return connect.NewResponse(&user.VerifyInviteCodeResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func createInviteCodeRequestToCommand(req *user.CreateInviteCodeRequest) (*command.CreateUserInvite, error) { @@ -394,33 +395,33 @@ func createInviteCodeRequestToCommand(req *user.CreateInviteCodeRequest) (*comma } } -func (s *Server) HumanMFAInitSkipped(ctx context.Context, req *user.HumanMFAInitSkippedRequest) (_ *user.HumanMFAInitSkippedResponse, err error) { - details, err := s.command.HumanMFAInitSkippedV2(ctx, req.UserId) +func (s *Server) HumanMFAInitSkipped(ctx context.Context, req *connect.Request[user.HumanMFAInitSkippedRequest]) (_ *connect.Response[user.HumanMFAInitSkippedResponse], err error) { + details, err := s.command.HumanMFAInitSkippedV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.HumanMFAInitSkippedResponse{ + return connect.NewResponse(&user.HumanMFAInitSkippedResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) CreateUser(ctx context.Context, req *user.CreateUserRequest) (*user.CreateUserResponse, error) { - switch userType := req.GetUserType().(type) { +func (s *Server) CreateUser(ctx context.Context, req *connect.Request[user.CreateUserRequest]) (*connect.Response[user.CreateUserResponse], error) { + switch userType := req.Msg.GetUserType().(type) { case *user.CreateUserRequest_Human_: - return s.createUserTypeHuman(ctx, userType.Human, req.OrganizationId, req.Username, req.UserId) + return s.createUserTypeHuman(ctx, userType.Human, req.Msg.GetOrganizationId(), req.Msg.Username, req.Msg.UserId) case *user.CreateUserRequest_Machine_: - return s.createUserTypeMachine(ctx, userType.Machine, req.OrganizationId, req.GetUsername(), req.GetUserId()) + return s.createUserTypeMachine(ctx, userType.Machine, req.Msg.GetOrganizationId(), req.Msg.GetUsername(), req.Msg.GetUserId()) default: return nil, zerrors.ThrowInternal(nil, "", "user type is not implemented") } } -func (s *Server) UpdateUser(ctx context.Context, req *user.UpdateUserRequest) (*user.UpdateUserResponse, error) { - switch userType := req.GetUserType().(type) { +func (s *Server) UpdateUser(ctx context.Context, req *connect.Request[user.UpdateUserRequest]) (*connect.Response[user.UpdateUserResponse], error) { + switch userType := req.Msg.GetUserType().(type) { case *user.UpdateUserRequest_Human_: - return s.updateUserTypeHuman(ctx, userType.Human, req.UserId, req.Username) + return s.updateUserTypeHuman(ctx, userType.Human, req.Msg.GetUserId(), req.Msg.Username) case *user.UpdateUserRequest_Machine_: - return s.updateUserTypeMachine(ctx, userType.Machine, req.UserId, req.Username) + return s.updateUserTypeMachine(ctx, userType.Machine, req.Msg.GetUserId(), req.Msg.Username) default: return nil, zerrors.ThrowUnimplemented(nil, "", "user type is not implemented") } diff --git a/internal/api/grpc/user/v2/user_query.go b/internal/api/grpc/user/v2/user_query.go index dc886462be..5f5603af31 100644 --- a/internal/api/grpc/user/v2/user_query.go +++ b/internal/api/grpc/user/v2/user_query.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,12 +14,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) (_ *user.GetUserByIDResponse, err error) { - resp, err := s.query.GetUserByIDWithPermission(ctx, true, req.GetUserId(), s.checkPermission) +func (s *Server) GetUserByID(ctx context.Context, req *connect.Request[user.GetUserByIDRequest]) (_ *connect.Response[user.GetUserByIDResponse], err error) { + resp, err := s.query.GetUserByIDWithPermission(ctx, true, req.Msg.GetUserId(), s.checkPermission) if err != nil { return nil, err } - return &user.GetUserByIDResponse{ + return connect.NewResponse(&user.GetUserByIDResponse{ Details: object.DomainToDetailsPb(&domain.ObjectDetails{ Sequence: resp.Sequence, CreationDate: resp.CreationDate, @@ -26,11 +27,11 @@ func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) ResourceOwner: resp.ResourceOwner, }), User: userToPb(resp, s.assetAPIPrefix(ctx)), - }, nil + }), nil } -func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { - queries, err := listUsersRequestToModel(req) +func (s *Server) ListUsers(ctx context.Context, req *connect.Request[user.ListUsersRequest]) (*connect.Response[user.ListUsersResponse], error) { + queries, err := listUsersRequestToModel(req.Msg) if err != nil { return nil, err } @@ -38,10 +39,10 @@ func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*us if err != nil { return nil, err } - return &user.ListUsersResponse{ + return connect.NewResponse(&user.ListUsersResponse{ Result: UsersToPb(res.Users, s.assetAPIPrefix(ctx)), Details: object.ToListDetails(res.SearchResponse), - }, nil + }), nil } func UsersToPb(users []*query.User, assetPrefix string) []*user.User { diff --git a/internal/api/grpc/user/v2beta/email.go b/internal/api/grpc/user/v2beta/email.go index 38cc73c75c..474111f767 100644 --- a/internal/api/grpc/user/v2beta/email.go +++ b/internal/api/grpc/user/v2beta/email.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) { +func (s *Server) SetEmail(ctx context.Context, req *connect.Request[user.SetEmailRequest]) (resp *connect.Response[user.SetEmailResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetEmailRequest_SendCode: - email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.SetEmailRequest_ReturnCode: - email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmailReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) case *user.SetEmailRequest_IsVerified: - email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), req.GetEmail()) + email, err = s.command.ChangeUserEmailVerified(ctx, req.Msg.GetUserId(), req.Msg.GetEmail()) case nil: - email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmail(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v) } @@ -30,26 +31,26 @@ func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp return nil, err } - return &user.SetEmailResponse{ + return connect.NewResponse(&user.SetEmailResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeRequest) (resp *user.ResendEmailCodeResponse, err error) { +func (s *Server) ResendEmailCode(ctx context.Context, req *connect.Request[user.ResendEmailCodeRequest]) (resp *connect.Response[user.ResendEmailCodeResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendEmailCodeRequest_SendCode: - email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.Msg.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.ResendEmailCodeRequest_ReturnCode: - email, err = s.command.ResendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - email, err = s.command.ResendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method ResendEmailCode not implemented", v) } @@ -57,30 +58,30 @@ func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeR return nil, err } - return &user.ResendEmailCodeResponse{ + return connect.NewResponse(&user.ResendEmailCodeResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) { +func (s *Server) VerifyEmail(ctx context.Context, req *connect.Request[user.VerifyEmailRequest]) (*connect.Response[user.VerifyEmailResponse], error) { details, err := s.command.VerifyUserEmail(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyEmailResponse{ + return connect.NewResponse(&user.VerifyEmailResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/integration_test/user_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go index 250322d66f..077ed02d0e 100644 --- a/internal/api/grpc/user/v2beta/integration_test/user_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -1773,7 +1773,7 @@ func TestServer_DeleteUser(t *testing.T) { request.UserId = resp.GetUserId() Instance.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) Instance.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) - Instance.CreateOrgMembership(t, CTX, request.UserId) + Instance.CreateOrgMembership(t, CTX, Instance.DefaultOrg.Id, request.UserId) }, }, want: &user.DeleteUserResponse{ @@ -2058,7 +2058,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2082,7 +2082,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2106,7 +2106,9 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - postForm: true, + url: "http://localhost:8000/sso", + parametersExisting: []string{"RelayState", "SAMLRequest"}, + postForm: true, }, wantErr: false, }, @@ -2120,9 +2122,11 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } require.NoError(t, err) - if tt.want.url != "" { + if tt.want.url != "" && !tt.want.postForm { authUrl, err := url.Parse(got.GetAuthUrl()) require.NoError(t, err) + + assert.Equal(t, tt.want.url, authUrl.Scheme+"://"+authUrl.Host+authUrl.Path) require.Len(t, authUrl.Query(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) for _, existing := range tt.want.parametersExisting { @@ -2133,7 +2137,15 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } } if tt.want.postForm { - assert.NotEmpty(t, got.GetPostForm()) + assert.Equal(t, tt.want.url, got.GetFormData().GetUrl()) + + require.Len(t, got.GetFormData().GetFields(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) + for _, existing := range tt.want.parametersExisting { + assert.Contains(t, got.GetFormData().GetFields(), existing) + } + for key, equal := range tt.want.parametersEqual { + assert.Equal(t, got.GetFormData().GetFields()[key], equal) + } } integration.AssertDetails(t, &user.StartIdentityProviderIntentResponse{ Details: tt.want.details, diff --git a/internal/api/grpc/user/v2beta/otp.go b/internal/api/grpc/user/v2beta/otp.go index c11aa4c1a4..99919ce047 100644 --- a/internal/api/grpc/user/v2beta/otp.go +++ b/internal/api/grpc/user/v2beta/otp.go @@ -3,40 +3,42 @@ package user import ( "context" + "connectrpc.com/connect" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) { - details, err := s.command.AddHumanOTPSMS(ctx, req.GetUserId(), "") +func (s *Server) AddOTPSMS(ctx context.Context, req *connect.Request[user.AddOTPSMSRequest]) (*connect.Response[user.AddOTPSMSResponse], error) { + details, err := s.command.AddHumanOTPSMS(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil + return connect.NewResponse(&user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}), nil } -func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) { - objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.GetUserId(), "") +func (s *Server) RemoveOTPSMS(ctx context.Context, req *connect.Request[user.RemoveOTPSMSRequest]) (*connect.Response[user.RemoveOTPSMSResponse], error) { + objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } -func (s *Server) AddOTPEmail(ctx context.Context, req *user.AddOTPEmailRequest) (*user.AddOTPEmailResponse, error) { - details, err := s.command.AddHumanOTPEmail(ctx, req.GetUserId(), "") +func (s *Server) AddOTPEmail(ctx context.Context, req *connect.Request[user.AddOTPEmailRequest]) (*connect.Response[user.AddOTPEmailResponse], error) { + details, err := s.command.AddHumanOTPEmail(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}, nil + return connect.NewResponse(&user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}), nil } -func (s *Server) RemoveOTPEmail(ctx context.Context, req *user.RemoveOTPEmailRequest) (*user.RemoveOTPEmailResponse, error) { - objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.GetUserId(), "") +func (s *Server) RemoveOTPEmail(ctx context.Context, req *connect.Request[user.RemoveOTPEmailRequest]) (*connect.Response[user.RemoveOTPEmailResponse], error) { + objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } diff --git a/internal/api/grpc/user/v2beta/passkey.go b/internal/api/grpc/user/v2beta/passkey.go index 2df267f3fd..a63ac708b4 100644 --- a/internal/api/grpc/user/v2beta/passkey.go +++ b/internal/api/grpc/user/v2beta/passkey.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/structpb" object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" @@ -12,17 +13,17 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) { +func (s *Server) RegisterPasskey(ctx context.Context, req *connect.Request[user.RegisterPasskeyRequest]) (resp *connect.Response[user.RegisterPasskeyResponse], err error) { var ( - authenticator = passkeyAuthenticatorToDomain(req.GetAuthenticator()) + authenticator = passkeyAuthenticatorToDomain(req.Msg.GetAuthenticator()) ) - if code := req.GetCode(); code != nil { + if code := req.Msg.GetCode(); code != nil { return passkeyRegistrationDetailsToPb( - s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), "", authenticator, code.Id, code.Code, req.GetDomain(), s.userCodeAlg), + s.command.RegisterUserPasskeyWithCode(ctx, req.Msg.GetUserId(), "", authenticator, code.Id, code.Code, req.Msg.GetDomain(), s.userCodeAlg), ) } return passkeyRegistrationDetailsToPb( - s.command.RegisterUserPasskey(ctx, req.GetUserId(), "", req.GetDomain(), authenticator), + s.command.RegisterUserPasskey(ctx, req.Msg.GetUserId(), "", req.Msg.GetDomain(), authenticator), ) } @@ -50,69 +51,69 @@ func webAuthNRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails return object.DomainToDetailsPb(details.ObjectDetails), options, nil } -func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) { +func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*connect.Response[user.RegisterPasskeyResponse], error) { objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) if err != nil { return nil, err } - return &user.RegisterPasskeyResponse{ + return connect.NewResponse(&user.RegisterPasskeyResponse{ Details: objectDetails, PasskeyId: details.ID, PublicKeyCredentialCreationOptions: options, - }, nil + }), nil } -func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) { - pkc, err := req.GetPublicKeyCredential().MarshalJSON() +func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *connect.Request[user.VerifyPasskeyRegistrationRequest]) (*connect.Response[user.VerifyPasskeyRegistrationResponse], error) { + pkc, err := req.Msg.GetPublicKeyCredential().MarshalJSON() if err != nil { return nil, zerrors.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal") } - objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), "", req.GetPasskeyName(), "", pkc) + objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.Msg.GetUserId(), "", req.Msg.GetPasskeyName(), "", pkc) if err != nil { return nil, err } - return &user.VerifyPasskeyRegistrationResponse{ + return connect.NewResponse(&user.VerifyPasskeyRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *user.CreatePasskeyRegistrationLinkRequest) (resp *user.CreatePasskeyRegistrationLinkResponse, err error) { - switch medium := req.Medium.(type) { +func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *connect.Request[user.CreatePasskeyRegistrationLinkRequest]) (resp *connect.Response[user.CreatePasskeyRegistrationLinkResponse], err error) { + switch medium := req.Msg.Medium.(type) { case nil: return passkeyDetailsToPb( - s.command.AddUserPasskeyCode(ctx, req.GetUserId(), "", s.userCodeAlg), + s.command.AddUserPasskeyCode(ctx, req.Msg.GetUserId(), "", s.userCodeAlg), ) case *user.CreatePasskeyRegistrationLinkRequest_SendLink: return passkeyDetailsToPb( - s.command.AddUserPasskeyCodeURLTemplate(ctx, req.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), + s.command.AddUserPasskeyCodeURLTemplate(ctx, req.Msg.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), ) case *user.CreatePasskeyRegistrationLinkRequest_ReturnCode: return passkeyCodeDetailsToPb( - s.command.AddUserPasskeyCodeReturn(ctx, req.GetUserId(), "", s.userCodeAlg), + s.command.AddUserPasskeyCodeReturn(ctx, req.Msg.GetUserId(), "", s.userCodeAlg), ) default: return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-gaD8y", "verification oneOf %T in method CreatePasskeyRegistrationLink not implemented", medium) } } -func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { +func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*connect.Response[user.CreatePasskeyRegistrationLinkResponse], error) { if err != nil { return nil, err } - return &user.CreatePasskeyRegistrationLinkResponse{ + return connect.NewResponse(&user.CreatePasskeyRegistrationLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { +func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*connect.Response[user.CreatePasskeyRegistrationLinkResponse], error) { if err != nil { return nil, err } - return &user.CreatePasskeyRegistrationLinkResponse{ + return connect.NewResponse(&user.CreatePasskeyRegistrationLinkResponse{ Details: object.DomainToDetailsPb(details.ObjectDetails), Code: &user.PasskeyRegistrationCode{ Id: details.CodeID, Code: details.Code, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/passkey_test.go b/internal/api/grpc/user/v2beta/passkey_test.go index f4a48ed941..12ef8ed02f 100644 --- a/internal/api/grpc/user/v2beta/passkey_test.go +++ b/internal/api/grpc/user/v2beta/passkey_test.go @@ -123,11 +123,11 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) } if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } @@ -181,7 +181,9 @@ func Test_passkeyDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) + if tt.want != nil { + assert.Equal(t, tt.want, got.Msg) + } }) } } @@ -242,9 +244,9 @@ func Test_passkeyCodeDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyCodeDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + assert.Equal(t, tt.want, got.Msg) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } diff --git a/internal/api/grpc/user/v2beta/password.go b/internal/api/grpc/user/v2beta/password.go index 0de1262215..ae9a549db0 100644 --- a/internal/api/grpc/user/v2beta/password.go +++ b/internal/api/grpc/user/v2beta/password.go @@ -3,23 +3,25 @@ package user import ( "context" + "connectrpc.com/connect" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) { +func (s *Server) PasswordReset(ctx context.Context, req *connect.Request[user.PasswordResetRequest]) (_ *connect.Response[user.PasswordResetResponse], err error) { var details *domain.ObjectDetails var code *string - switch m := req.GetMedium().(type) { + switch m := req.Msg.GetMedium().(type) { case *user.PasswordResetRequest_SendLink: - details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) + details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.Msg.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) case *user.PasswordResetRequest_ReturnCode: - details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.GetUserId()) + details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.Msg.GetUserId()) case nil: - details, code, err = s.command.RequestPasswordReset(ctx, req.GetUserId()) + details, code, err = s.command.RequestPasswordReset(ctx, req.Msg.GetUserId()) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-SDeeg", "verification oneOf %T in method RequestPasswordReset not implemented", m) } @@ -27,10 +29,10 @@ func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetReque return nil, err } - return &user.PasswordResetResponse{ + return connect.NewResponse(&user.PasswordResetResponse{ Details: object.DomainToDetailsPb(details), VerificationCode: code, - }, nil + }), nil } func notificationTypeToDomain(notificationType user.NotificationType) domain.NotificationType { @@ -46,16 +48,16 @@ func notificationTypeToDomain(notificationType user.NotificationType) domain.Not } } -func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) (_ *user.SetPasswordResponse, err error) { +func (s *Server) SetPassword(ctx context.Context, req *connect.Request[user.SetPasswordRequest]) (_ *connect.Response[user.SetPasswordResponse], err error) { var details *domain.ObjectDetails - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetPasswordRequest_CurrentPassword: - details, err = s.command.ChangePassword(ctx, "", req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + details, err = s.command.ChangePassword(ctx, "", req.Msg.GetUserId(), v.CurrentPassword, req.Msg.GetNewPassword().GetPassword(), "", req.Msg.GetNewPassword().GetChangeRequired()) case *user.SetPasswordRequest_VerificationCode: - details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.Msg.GetUserId(), v.VerificationCode, req.Msg.GetNewPassword().GetPassword(), "", req.Msg.GetNewPassword().GetChangeRequired()) case nil: - details, err = s.command.SetPassword(ctx, "", req.GetUserId(), req.GetNewPassword().GetPassword(), req.GetNewPassword().GetChangeRequired()) + details, err = s.command.SetPassword(ctx, "", req.Msg.GetUserId(), req.Msg.GetNewPassword().GetPassword(), req.Msg.GetNewPassword().GetChangeRequired()) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-SFdf2", "verification oneOf %T in method SetPasswordRequest not implemented", v) } @@ -63,7 +65,7 @@ func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) return nil, err } - return &user.SetPasswordResponse{ + return connect.NewResponse(&user.SetPasswordResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/phone.go b/internal/api/grpc/user/v2beta/phone.go index eac7eb4e31..20ef2075ab 100644 --- a/internal/api/grpc/user/v2beta/phone.go +++ b/internal/api/grpc/user/v2beta/phone.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) { +func (s *Server) SetPhone(ctx context.Context, req *connect.Request[user.SetPhoneRequest]) (resp *connect.Response[user.SetPhoneResponse], err error) { var phone *domain.Phone - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetPhoneRequest_SendCode: - phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhone(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) case *user.SetPhoneRequest_ReturnCode: - phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) case *user.SetPhoneRequest_IsVerified: - phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), req.GetPhone()) + phone, err = s.command.ChangeUserPhoneVerified(ctx, req.Msg.GetUserId(), req.Msg.GetPhone()) case nil: - phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhone(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetPhone not implemented", v) } @@ -30,42 +31,42 @@ func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp return nil, err } - return &user.SetPhoneResponse{ + return connect.NewResponse(&user.SetPhoneResponse{ Details: &object.Details{ Sequence: phone.Sequence, ChangeDate: timestamppb.New(phone.ChangeDate), ResourceOwner: phone.ResourceOwner, }, VerificationCode: phone.PlainCode, - }, nil + }), nil } -func (s *Server) RemovePhone(ctx context.Context, req *user.RemovePhoneRequest) (resp *user.RemovePhoneResponse, err error) { +func (s *Server) RemovePhone(ctx context.Context, req *connect.Request[user.RemovePhoneRequest]) (resp *connect.Response[user.RemovePhoneResponse], err error) { details, err := s.command.RemoveUserPhone(ctx, - req.GetUserId(), + req.Msg.GetUserId(), ) if err != nil { return nil, err } - return &user.RemovePhoneResponse{ + return connect.NewResponse(&user.RemovePhoneResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeRequest) (resp *user.ResendPhoneCodeResponse, err error) { +func (s *Server) ResendPhoneCode(ctx context.Context, req *connect.Request[user.ResendPhoneCodeRequest]) (resp *connect.Response[user.ResendPhoneCodeResponse], err error) { var phone *domain.Phone - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendPhoneCodeRequest_SendCode: - phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case *user.ResendPhoneCodeRequest_ReturnCode: - phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-ResendUserPhoneCode", "verification oneOf %T in method SetPhone not implemented", v) } @@ -73,30 +74,30 @@ func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeR return nil, err } - return &user.ResendPhoneCodeResponse{ + return connect.NewResponse(&user.ResendPhoneCodeResponse{ Details: &object.Details{ Sequence: phone.Sequence, ChangeDate: timestamppb.New(phone.ChangeDate), ResourceOwner: phone.ResourceOwner, }, VerificationCode: phone.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyPhone(ctx context.Context, req *user.VerifyPhoneRequest) (*user.VerifyPhoneResponse, error) { +func (s *Server) VerifyPhone(ctx context.Context, req *connect.Request[user.VerifyPhoneRequest]) (*connect.Response[user.VerifyPhoneResponse], error) { details, err := s.command.VerifyUserPhone(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyPhoneResponse{ + return connect.NewResponse(&user.VerifyPhoneResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go index 46b009a72e..b9654ea97c 100644 --- a/internal/api/grpc/user/v2beta/query.go +++ b/internal/api/grpc/user/v2beta/query.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,23 +14,23 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) (_ *user.GetUserByIDResponse, err error) { - resp, err := s.query.GetUserByIDWithPermission(ctx, true, req.GetUserId(), s.checkPermission) +func (s *Server) GetUserByID(ctx context.Context, req *connect.Request[user.GetUserByIDRequest]) (_ *connect.Response[user.GetUserByIDResponse], err error) { + resp, err := s.query.GetUserByIDWithPermission(ctx, true, req.Msg.GetUserId(), s.checkPermission) if err != nil { return nil, err } - return &user.GetUserByIDResponse{ + return connect.NewResponse(&user.GetUserByIDResponse{ Details: object.DomainToDetailsPb(&domain.ObjectDetails{ Sequence: resp.Sequence, EventDate: resp.ChangeDate, ResourceOwner: resp.ResourceOwner, }), User: userToPb(resp, s.assetAPIPrefix(ctx)), - }, nil + }), nil } -func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { - queries, err := listUsersRequestToModel(req) +func (s *Server) ListUsers(ctx context.Context, req *connect.Request[user.ListUsersRequest]) (*connect.Response[user.ListUsersResponse], error) { + queries, err := listUsersRequestToModel(req.Msg) if err != nil { return nil, err } @@ -37,10 +38,10 @@ func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*us if err != nil { return nil, err } - return &user.ListUsersResponse{ + return connect.NewResponse(&user.ListUsersResponse{ Result: UsersToPb(res.Users, s.assetAPIPrefix(ctx)), Details: object.ToListDetails(res.SearchResponse), - }, nil + }), nil } func UsersToPb(users []*query.User, assetPrefix string) []*user.User { diff --git a/internal/api/grpc/user/v2beta/server.go b/internal/api/grpc/user/v2beta/server.go index 93af47f58b..7e3934a2c1 100644 --- a/internal/api/grpc/user/v2beta/server.go +++ b/internal/api/grpc/user/v2beta/server.go @@ -2,8 +2,10 @@ package user import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -12,12 +14,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2beta/userconnect" ) -var _ user.UserServiceServer = (*Server)(nil) +var _ userconnect.UserServiceHandler = (*Server)(nil) type Server struct { - user.UnimplementedUserServiceServer command *command.Commands query *query.Queries userCodeAlg crypto.EncryptionAlgorithm @@ -54,8 +56,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - user.RegisterUserServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return userconnect.NewUserServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return user.File_zitadel_user_v2beta_user_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/user/v2beta/totp.go b/internal/api/grpc/user/v2beta/totp.go index 2ef47a9817..e7bd01b2b6 100644 --- a/internal/api/grpc/user/v2beta/totp.go +++ b/internal/api/grpc/user/v2beta/totp.go @@ -3,42 +3,44 @@ package user import ( "context" + "connectrpc.com/connect" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" "github.com/zitadel/zitadel/internal/domain" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) RegisterTOTP(ctx context.Context, req *user.RegisterTOTPRequest) (*user.RegisterTOTPResponse, error) { +func (s *Server) RegisterTOTP(ctx context.Context, req *connect.Request[user.RegisterTOTPRequest]) (*connect.Response[user.RegisterTOTPResponse], error) { return totpDetailsToPb( - s.command.AddUserTOTP(ctx, req.GetUserId(), ""), + s.command.AddUserTOTP(ctx, req.Msg.GetUserId(), ""), ) } -func totpDetailsToPb(totp *domain.TOTP, err error) (*user.RegisterTOTPResponse, error) { +func totpDetailsToPb(totp *domain.TOTP, err error) (*connect.Response[user.RegisterTOTPResponse], error) { if err != nil { return nil, err } - return &user.RegisterTOTPResponse{ + return connect.NewResponse(&user.RegisterTOTPResponse{ Details: object.DomainToDetailsPb(totp.ObjectDetails), Uri: totp.URI, Secret: totp.Secret, - }, nil + }), nil } -func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *user.VerifyTOTPRegistrationRequest) (*user.VerifyTOTPRegistrationResponse, error) { - objectDetails, err := s.command.CheckUserTOTP(ctx, req.GetUserId(), req.GetCode(), "") +func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *connect.Request[user.VerifyTOTPRegistrationRequest]) (*connect.Response[user.VerifyTOTPRegistrationResponse], error) { + objectDetails, err := s.command.CheckUserTOTP(ctx, req.Msg.GetUserId(), req.Msg.GetCode(), "") if err != nil { return nil, err } - return &user.VerifyTOTPRegistrationResponse{ + return connect.NewResponse(&user.VerifyTOTPRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) RemoveTOTP(ctx context.Context, req *user.RemoveTOTPRequest) (*user.RemoveTOTPResponse, error) { - objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.GetUserId(), "") +func (s *Server) RemoveTOTP(ctx context.Context, req *connect.Request[user.RemoveTOTPRequest]) (*connect.Response[user.RemoveTOTPResponse], error) { + objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } diff --git a/internal/api/grpc/user/v2beta/totp_test.go b/internal/api/grpc/user/v2beta/totp_test.go index 81a54675f2..77c6e5c343 100644 --- a/internal/api/grpc/user/v2beta/totp_test.go +++ b/internal/api/grpc/user/v2beta/totp_test.go @@ -63,7 +63,7 @@ func Test_totpDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := totpDetailsToPb(tt.args.otp, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("RegisterTOTPResponse =\n%v\nwant\n%v", got, tt.want) } }) diff --git a/internal/api/grpc/user/v2beta/u2f.go b/internal/api/grpc/user/v2beta/u2f.go index e23a22b8b5..a6823a4bc0 100644 --- a/internal/api/grpc/user/v2beta/u2f.go +++ b/internal/api/grpc/user/v2beta/u2f.go @@ -3,40 +3,42 @@ package user import ( "context" + "connectrpc.com/connect" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) { +func (s *Server) RegisterU2F(ctx context.Context, req *connect.Request[user.RegisterU2FRequest]) (*connect.Response[user.RegisterU2FResponse], error) { return u2fRegistrationDetailsToPb( - s.command.RegisterUserU2F(ctx, req.GetUserId(), "", req.GetDomain()), + s.command.RegisterUserU2F(ctx, req.Msg.GetUserId(), "", req.Msg.GetDomain()), ) } -func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterU2FResponse, error) { +func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*connect.Response[user.RegisterU2FResponse], error) { objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) if err != nil { return nil, err } - return &user.RegisterU2FResponse{ + return connect.NewResponse(&user.RegisterU2FResponse{ Details: objectDetails, U2FId: details.ID, PublicKeyCredentialCreationOptions: options, - }, nil + }), nil } -func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FRegistrationRequest) (*user.VerifyU2FRegistrationResponse, error) { - pkc, err := req.GetPublicKeyCredential().MarshalJSON() +func (s *Server) VerifyU2FRegistration(ctx context.Context, req *connect.Request[user.VerifyU2FRegistrationRequest]) (*connect.Response[user.VerifyU2FRegistrationResponse], error) { + pkc, err := req.Msg.GetPublicKeyCredential().MarshalJSON() if err != nil { return nil, zerrors.ThrowInternal(err, "USERv2-IeTh4", "Errors.Internal") } - objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.GetUserId(), "", req.GetTokenName(), "", pkc) + objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.Msg.GetUserId(), "", req.Msg.GetTokenName(), "", pkc) if err != nil { return nil, err } - return &user.VerifyU2FRegistrationResponse{ + return connect.NewResponse(&user.VerifyU2FRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/u2f_test.go b/internal/api/grpc/user/v2beta/u2f_test.go index 53f2a0bb8c..ac99c0d1eb 100644 --- a/internal/api/grpc/user/v2beta/u2f_test.go +++ b/internal/api/grpc/user/v2beta/u2f_test.go @@ -92,11 +92,11 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := u2fRegistrationDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) } if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } diff --git a/internal/api/grpc/user/v2beta/user.go b/internal/api/grpc/user/v2beta/user.go index 93afbde0aa..3cde7b773e 100644 --- a/internal/api/grpc/user/v2beta/user.go +++ b/internal/api/grpc/user/v2beta/user.go @@ -6,6 +6,7 @@ import ( "io" "time" + "connectrpc.com/connect" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -23,8 +24,8 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { - human, err := AddUserRequestToAddHuman(req) +func (s *Server) AddHumanUser(ctx context.Context, req *connect.Request[user.AddHumanUserRequest]) (_ *connect.Response[user.AddHumanUserResponse], err error) { + human, err := AddUserRequestToAddHuman(req.Msg) if err != nil { return nil, err } @@ -32,12 +33,12 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest if err = s.command.AddUserHuman(ctx, orgID, human, false, s.userCodeAlg); err != nil { return nil, err } - return &user.AddHumanUserResponse{ + return connect.NewResponse(&user.AddHumanUserResponse{ UserId: human.ID, Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) { @@ -115,8 +116,8 @@ func genderToDomain(gender user.Gender) domain.Gender { } } -func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) { - human, err := UpdateUserRequestToChangeHuman(req) +func (s *Server) UpdateHumanUser(ctx context.Context, req *connect.Request[user.UpdateHumanUserRequest]) (_ *connect.Response[user.UpdateHumanUserResponse], err error) { + human, err := UpdateUserRequestToChangeHuman(req.Msg) if err != nil { return nil, err } @@ -124,51 +125,51 @@ func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserR if err != nil { return nil, err } - return &user.UpdateHumanUserResponse{ + return connect.NewResponse(&user.UpdateHumanUserResponse{ Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } -func (s *Server) LockUser(ctx context.Context, req *user.LockUserRequest) (_ *user.LockUserResponse, err error) { - details, err := s.command.LockUserV2(ctx, req.UserId) +func (s *Server) LockUser(ctx context.Context, req *connect.Request[user.LockUserRequest]) (_ *connect.Response[user.LockUserResponse], err error) { + details, err := s.command.LockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.LockUserResponse{ + return connect.NewResponse(&user.LockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) UnlockUser(ctx context.Context, req *user.UnlockUserRequest) (_ *user.UnlockUserResponse, err error) { - details, err := s.command.UnlockUserV2(ctx, req.UserId) +func (s *Server) UnlockUser(ctx context.Context, req *connect.Request[user.UnlockUserRequest]) (_ *connect.Response[user.UnlockUserResponse], err error) { + details, err := s.command.UnlockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.UnlockUserResponse{ + return connect.NewResponse(&user.UnlockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { - details, err := s.command.DeactivateUserV2(ctx, req.UserId) +func (s *Server) DeactivateUser(ctx context.Context, req *connect.Request[user.DeactivateUserRequest]) (_ *connect.Response[user.DeactivateUserResponse], err error) { + details, err := s.command.DeactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.DeactivateUserResponse{ + return connect.NewResponse(&user.DeactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ReactivateUser(ctx context.Context, req *user.ReactivateUserRequest) (_ *user.ReactivateUserResponse, err error) { - details, err := s.command.ReactivateUserV2(ctx, req.UserId) +func (s *Server) ReactivateUser(ctx context.Context, req *connect.Request[user.ReactivateUserRequest]) (_ *connect.Response[user.ReactivateUserResponse], err error) { + details, err := s.command.ReactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.ReactivateUserResponse{ + return connect.NewResponse(&user.ReactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { @@ -260,32 +261,32 @@ func SetHumanPasswordToPassword(password *user.SetPassword) *command.Password { } } -func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { - details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{ - IDPID: req.GetIdpLink().GetIdpId(), - DisplayName: req.GetIdpLink().GetUserName(), - IDPExternalID: req.GetIdpLink().GetUserId(), +func (s *Server) AddIDPLink(ctx context.Context, req *connect.Request[user.AddIDPLinkRequest]) (_ *connect.Response[user.AddIDPLinkResponse], err error) { + details, err := s.command.AddUserIDPLink(ctx, req.Msg.GetUserId(), "", &command.AddLink{ + IDPID: req.Msg.GetIdpLink().GetIdpId(), + DisplayName: req.Msg.GetIdpLink().GetUserName(), + IDPExternalID: req.Msg.GetIdpLink().GetUserId(), }) if err != nil { return nil, err } - return &user.AddIDPLinkResponse{ + return connect.NewResponse(&user.AddIDPLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { - memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) +func (s *Server) DeleteUser(ctx context.Context, req *connect.Request[user.DeleteUserRequest]) (_ *connect.Response[user.DeleteUserResponse], err error) { + memberships, grants, err := s.removeUserDependencies(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - details, err := s.command.RemoveUserV2(ctx, req.UserId, "", memberships, grants...) + details, err := s.command.RemoveUserV2(ctx, req.Msg.GetUserId(), "", memberships, grants...) if err != nil { return nil, err } - return &user.DeleteUserResponse{ + return connect.NewResponse(&user.DeleteUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { @@ -295,7 +296,7 @@ func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]* } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, - }, true) + }, true, nil) if err != nil { return nil, nil, err } @@ -360,18 +361,18 @@ func userGrantsToIDs(userGrants []*query.UserGrant) []string { return converted } -func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *user.StartIdentityProviderIntentRequest) (_ *user.StartIdentityProviderIntentResponse, err error) { - switch t := req.GetContent().(type) { +func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *connect.Request[user.StartIdentityProviderIntentRequest]) (_ *connect.Response[user.StartIdentityProviderIntentResponse], err error) { + switch t := req.Msg.GetContent().(type) { case *user.StartIdentityProviderIntentRequest_Urls: - return s.startIDPIntent(ctx, req.GetIdpId(), t.Urls) + return s.startIDPIntent(ctx, req.Msg.GetIdpId(), t.Urls) case *user.StartIdentityProviderIntentRequest_Ldap: - return s.startLDAPIntent(ctx, req.GetIdpId(), t.Ldap) + return s.startLDAPIntent(ctx, req.Msg.GetIdpId(), t.Ldap) default: return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-S2g21", "type oneOf %T in method StartIdentityProviderIntent not implemented", t) } } -func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderIntentResponse, error) { +func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*connect.Response[user.StartIdentityProviderIntentResponse], error) { state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID)) if err != nil { return nil, err @@ -380,22 +381,31 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re if err != nil { return nil, err } - content, redirect := session.GetAuth(ctx) - if redirect { - return &user.StartIdentityProviderIntentResponse{ - Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: content}, - }, nil + auth, err := session.GetAuth(ctx) + if err != nil { + return nil, err } - return &user.StartIdentityProviderIntentResponse{ - Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ - PostForm: []byte(content), - }, - }, nil + switch a := auth.(type) { + case *idp.RedirectAuth: + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: a.RedirectURL}, + }), nil + case *idp.FormAuth: + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_FormData{ + FormData: &user.FormData{ + Url: a.URL, + Fields: a.Fields, + }, + }, + }), nil + } + return nil, zerrors.ThrowInvalidArgumentf(nil, "USERv2-3g2j3", "type oneOf %T in method StartIdentityProviderIntent not implemented", auth) } -func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { +func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*connect.Response[user.StartIdentityProviderIntentResponse], error) { intentWriteModel, details, err := s.command.CreateIntent(ctx, "", idpID, "", "", authz.GetInstance(ctx).InstanceID(), nil) if err != nil { return nil, err @@ -411,7 +421,7 @@ func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredenti if err != nil { return nil, err } - return &user.StartIdentityProviderIntentResponse{ + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), NextStep: &user.StartIdentityProviderIntentResponse_IdpIntent{ IdpIntent: &user.IDPIntent{ @@ -420,7 +430,7 @@ func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredenti UserId: userID, }, }, - }, nil + }), nil } func (s *Server) checkLinkedExternalUser(ctx context.Context, idpID, externalUserID string) (string, error) { @@ -474,12 +484,12 @@ func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string return externalUser, userID, session, nil } -func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.RetrieveIdentityProviderIntentRequest) (_ *user.RetrieveIdentityProviderIntentResponse, err error) { - intent, err := s.command.GetIntentWriteModel(ctx, req.GetIdpIntentId(), "") +func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *connect.Request[user.RetrieveIdentityProviderIntentRequest]) (_ *connect.Response[user.RetrieveIdentityProviderIntentResponse], err error) { + intent, err := s.command.GetIntentWriteModel(ctx, req.Msg.GetIdpIntentId(), "") if err != nil { return nil, err } - if err := s.checkIntentToken(req.GetIdpIntentToken(), intent.AggregateID); err != nil { + if err := s.checkIntentToken(req.Msg.GetIdpIntentToken(), intent.AggregateID); err != nil { return nil, err } if intent.State != domain.IDPIntentStateSucceeded { @@ -491,7 +501,7 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R return idpIntentToIDPIntentPb(intent, s.idpAlg) } -func idpIntentToIDPIntentPb(intent *command.IDPIntentWriteModel, alg crypto.EncryptionAlgorithm) (_ *user.RetrieveIdentityProviderIntentResponse, err error) { +func idpIntentToIDPIntentPb(intent *command.IDPIntentWriteModel, alg crypto.EncryptionAlgorithm) (_ *connect.Response[user.RetrieveIdentityProviderIntentResponse], err error) { rawInformation := new(structpb.Struct) err = rawInformation.UnmarshalJSON(intent.IDPUser) if err != nil { @@ -530,7 +540,7 @@ func idpIntentToIDPIntentPb(intent *command.IDPIntentWriteModel, alg crypto.Encr information.IdpInformation.Access = IDPSAMLResponseToPb(assertion) } - return information, nil + return connect.NewResponse(information), nil } func idpOAuthTokensToPb(idpIDToken string, idpAccessToken *crypto.CryptoValue, alg crypto.EncryptionAlgorithm) (_ *user.IDPInformation_Oauth, err error) { @@ -593,15 +603,15 @@ func (s *Server) checkIntentToken(token string, intentID string) error { return crypto.CheckToken(s.idpAlg, token, intentID) } -func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.ListAuthenticationMethodTypesRequest) (*user.ListAuthenticationMethodTypesResponse, error) { - authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.GetUserId(), true, false, "") +func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *connect.Request[user.ListAuthenticationMethodTypesRequest]) (*connect.Response[user.ListAuthenticationMethodTypesResponse], error) { + authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.Msg.GetUserId(), true, false, "") if err != nil { return nil, err } - return &user.ListAuthenticationMethodTypesResponse{ + return connect.NewResponse(&user.ListAuthenticationMethodTypesResponse{ Details: object.ToListDetails(authMethods.SearchResponse), AuthMethodTypes: authMethodTypesToPb(authMethods.AuthMethodTypes), - }, nil + }), nil } func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType { diff --git a/internal/api/grpc/user/v2beta/user_test.go b/internal/api/grpc/user/v2beta/user_test.go index 9e398e83ff..8973d61fcc 100644 --- a/internal/api/grpc/user/v2beta/user_test.go +++ b/internal/api/grpc/user/v2beta/user_test.go @@ -322,7 +322,9 @@ func Test_idpIntentToIDPIntentPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := idpIntentToIDPIntentPb(tt.args.intent, tt.args.alg) require.ErrorIs(t, err, tt.res.err) - grpc.AllFieldsEqual(t, tt.res.resp.ProtoReflect(), got.ProtoReflect(), grpc.CustomMappers) + if tt.res.resp != nil { + grpc.AllFieldsEqual(t, tt.res.resp.ProtoReflect(), got.Msg.ProtoReflect(), grpc.CustomMappers) + } }) } } diff --git a/internal/api/grpc/webkey/v2/integration_test/webkey_integration_test.go b/internal/api/grpc/webkey/v2/integration_test/webkey_integration_test.go new file mode 100644 index 0000000000..48777927cf --- /dev/null +++ b/internal/api/grpc/webkey/v2/integration_test/webkey_integration_test.go @@ -0,0 +1,216 @@ +//go:build integration + +package webkey_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" +) + +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() + }()) +} + +func TestServer_ListWebKeys(t *testing.T) { + instance, iamCtx, creationDate := createInstance(t) + // After the feature is first enabled, we can expect 2 generated keys with the default config. + checkWebKeyListState(iamCtx, t, instance, 2, "", &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, creationDate) +} + +func TestServer_CreateWebKey(t *testing.T) { + instance, iamCtx, creationDate := createInstance(t) + client := instance.Client.WebKeyV2 + + _, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, + }) + require.NoError(t, err) + + checkWebKeyListState(iamCtx, t, instance, 3, "", &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, creationDate) +} + +func TestServer_ActivateWebKey(t *testing.T) { + instance, iamCtx, creationDate := createInstance(t) + client := instance.Client.WebKeyV2 + + resp, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, + }) + require.NoError(t, err) + + _, err = client.ActivateWebKey(iamCtx, &webkey.ActivateWebKeyRequest{ + Id: resp.GetId(), + }) + require.NoError(t, err) + + checkWebKeyListState(iamCtx, t, instance, 3, resp.GetId(), &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, creationDate) +} + +func TestServer_DeleteWebKey(t *testing.T) { + instance, iamCtx, creationDate := createInstance(t) + client := instance.Client.WebKeyV2 + + keyIDs := make([]string, 2) + for i := 0; i < 2; i++ { + resp, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, + }) + require.NoError(t, err) + keyIDs[i] = resp.GetId() + } + _, err := client.ActivateWebKey(iamCtx, &webkey.ActivateWebKeyRequest{ + Id: keyIDs[0], + }) + require.NoError(t, err) + + ok := t.Run("cannot delete active key", func(t *testing.T) { + _, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ + Id: keyIDs[0], + }) + require.Error(t, err) + s := status.Convert(err) + assert.Equal(t, codes.FailedPrecondition, s.Code()) + assert.Contains(t, s.Message(), "COMMAND-Chai1") + }) + if !ok { + return + } + + start := time.Now() + ok = t.Run("delete inactive key", func(t *testing.T) { + resp, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ + Id: keyIDs[1], + }) + require.NoError(t, err) + require.WithinRange(t, resp.GetDeletionDate().AsTime(), start, time.Now()) + }) + if !ok { + return + } + + ok = t.Run("delete inactive key again", func(t *testing.T) { + resp, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ + Id: keyIDs[1], + }) + require.NoError(t, err) + require.WithinRange(t, resp.GetDeletionDate().AsTime(), start, time.Now()) + }) + if !ok { + return + } + + ok = t.Run("delete not existing key", func(t *testing.T) { + resp, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ + Id: "not-existing", + }) + require.NoError(t, err) + require.Nil(t, resp.DeletionDate) + }) + if !ok { + return + } + + // There are 2 keys from feature setup, +2 created, -1 deleted = 3 + checkWebKeyListState(iamCtx, t, instance, 3, keyIDs[0], &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, creationDate) +} + +func createInstance(t *testing.T) (*integration.Instance, context.Context, *timestamppb.Timestamp) { + instance := integration.NewInstance(CTX) + creationDate := timestamppb.Now() + iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamCTX, time.Minute) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + resp, err := instance.Client.WebKeyV2.ListWebKeys(iamCTX, &webkey.ListWebKeysRequest{}) + assert.NoError(collect, err) + assert.Len(collect, resp.GetWebKeys(), 2) + + }, retryDuration, tick) + + return instance, iamCTX, creationDate +} + +func checkWebKeyListState(ctx context.Context, t *testing.T, instance *integration.Instance, nKeys int, expectActiveKeyID string, config any, creationDate *timestamppb.Timestamp) { + t.Helper() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + resp, err := instance.Client.WebKeyV2.ListWebKeys(ctx, &webkey.ListWebKeysRequest{}) + require.NoError(collect, err) + list := resp.GetWebKeys() + assert.Len(collect, list, nKeys) + + now := time.Now() + var gotActiveKeyID string + for _, key := range list { + assert.WithinRange(collect, key.GetCreationDate().AsTime(), now.Add(-time.Minute), now.Add(time.Minute)) + assert.WithinRange(collect, key.GetChangeDate().AsTime(), now.Add(-time.Minute), now.Add(time.Minute)) + assert.NotEqual(collect, webkey.State_STATE_UNSPECIFIED, key.GetState()) + assert.NotEqual(collect, webkey.State_STATE_REMOVED, key.GetState()) + assert.Equal(collect, config, key.GetKey()) + + if key.GetState() == webkey.State_STATE_ACTIVE { + gotActiveKeyID = key.GetId() + } + } + assert.NotEmpty(collect, gotActiveKeyID) + if expectActiveKeyID != "" { + assert.Equal(collect, expectActiveKeyID, gotActiveKeyID) + } + }, retryDuration, tick) +} diff --git a/internal/api/grpc/webkey/v2/server.go b/internal/api/grpc/webkey/v2/server.go new file mode 100644 index 0000000000..a62c29e2b9 --- /dev/null +++ b/internal/api/grpc/webkey/v2/server.go @@ -0,0 +1,51 @@ +package webkey + +import ( + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2/webkeyconnect" +) + +var _ webkeyconnect.WebKeyServiceHandler = (*Server)(nil) + +type Server struct { + command *command.Commands + query *query.Queries +} + +func CreateServer( + command *command.Commands, + query *query.Queries, +) *Server { + return &Server{ + command: command, + query: query, + } +} + +func (s *Server) AppName() string { + return webkey.WebKeyService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return webkey.WebKeyService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return webkey.WebKeyService_AuthMethods +} + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return webkeyconnect.NewWebKeyServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return webkey.File_zitadel_webkey_v2_webkey_service_proto +} diff --git a/internal/api/grpc/webkey/v2/webkey.go b/internal/api/grpc/webkey/v2/webkey.go new file mode 100644 index 0000000000..d1a10a31d0 --- /dev/null +++ b/internal/api/grpc/webkey/v2/webkey.go @@ -0,0 +1,72 @@ +package webkey + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" +) + +func (s *Server) CreateWebKey(ctx context.Context, req *connect.Request[webkey.CreateWebKeyRequest]) (_ *connect.Response[webkey.CreateWebKeyResponse], err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + webKey, err := s.command.CreateWebKey(ctx, createWebKeyRequestToConfig(req.Msg)) + if err != nil { + return nil, err + } + + return connect.NewResponse(&webkey.CreateWebKeyResponse{ + Id: webKey.KeyID, + CreationDate: timestamppb.New(webKey.ObjectDetails.EventDate), + }), nil +} + +func (s *Server) ActivateWebKey(ctx context.Context, req *connect.Request[webkey.ActivateWebKeyRequest]) (_ *connect.Response[webkey.ActivateWebKeyResponse], err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + details, err := s.command.ActivateWebKey(ctx, req.Msg.GetId()) + if err != nil { + return nil, err + } + + return connect.NewResponse(&webkey.ActivateWebKeyResponse{ + ChangeDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) DeleteWebKey(ctx context.Context, req *connect.Request[webkey.DeleteWebKeyRequest]) (_ *connect.Response[webkey.DeleteWebKeyResponse], err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + deletedAt, err := s.command.DeleteWebKey(ctx, req.Msg.GetId()) + if err != nil { + return nil, err + } + + var deletionDate *timestamppb.Timestamp + if !deletedAt.IsZero() { + deletionDate = timestamppb.New(deletedAt) + } + return connect.NewResponse(&webkey.DeleteWebKeyResponse{ + DeletionDate: deletionDate, + }), nil +} + +func (s *Server) ListWebKeys(ctx context.Context, _ *connect.Request[webkey.ListWebKeysRequest]) (_ *connect.Response[webkey.ListWebKeysResponse], err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + list, err := s.query.ListWebKeys(ctx) + if err != nil { + return nil, err + } + + return connect.NewResponse(&webkey.ListWebKeysResponse{ + WebKeys: webKeyDetailsListToPb(list), + }), nil +} diff --git a/internal/api/grpc/webkey/v2/webkey_converter.go b/internal/api/grpc/webkey/v2/webkey_converter.go new file mode 100644 index 0000000000..7ee7fbce05 --- /dev/null +++ b/internal/api/grpc/webkey/v2/webkey_converter.go @@ -0,0 +1,170 @@ +package webkey + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" +) + +func createWebKeyRequestToConfig(req *webkey.CreateWebKeyRequest) crypto.WebKeyConfig { + switch config := req.GetKey().(type) { + case *webkey.CreateWebKeyRequest_Rsa: + return rsaToCrypto(config.Rsa) + case *webkey.CreateWebKeyRequest_Ecdsa: + return ecdsaToCrypto(config.Ecdsa) + case *webkey.CreateWebKeyRequest_Ed25519: + return new(crypto.WebKeyED25519Config) + default: + return rsaToCrypto(nil) + } +} + +func rsaToCrypto(config *webkey.RSA) *crypto.WebKeyRSAConfig { + out := new(crypto.WebKeyRSAConfig) + + switch config.GetBits() { + case webkey.RSABits_RSA_BITS_UNSPECIFIED: + out.Bits = crypto.RSABits2048 + case webkey.RSABits_RSA_BITS_2048: + out.Bits = crypto.RSABits2048 + case webkey.RSABits_RSA_BITS_3072: + out.Bits = crypto.RSABits3072 + case webkey.RSABits_RSA_BITS_4096: + out.Bits = crypto.RSABits4096 + default: + out.Bits = crypto.RSABits2048 + } + + switch config.GetHasher() { + case webkey.RSAHasher_RSA_HASHER_UNSPECIFIED: + out.Hasher = crypto.RSAHasherSHA256 + case webkey.RSAHasher_RSA_HASHER_SHA256: + out.Hasher = crypto.RSAHasherSHA256 + case webkey.RSAHasher_RSA_HASHER_SHA384: + out.Hasher = crypto.RSAHasherSHA384 + case webkey.RSAHasher_RSA_HASHER_SHA512: + out.Hasher = crypto.RSAHasherSHA512 + default: + out.Hasher = crypto.RSAHasherSHA256 + } + + return out +} + +func ecdsaToCrypto(config *webkey.ECDSA) *crypto.WebKeyECDSAConfig { + out := new(crypto.WebKeyECDSAConfig) + + switch config.GetCurve() { + case webkey.ECDSACurve_ECDSA_CURVE_UNSPECIFIED: + out.Curve = crypto.EllipticCurveP256 + case webkey.ECDSACurve_ECDSA_CURVE_P256: + out.Curve = crypto.EllipticCurveP256 + case webkey.ECDSACurve_ECDSA_CURVE_P384: + out.Curve = crypto.EllipticCurveP384 + case webkey.ECDSACurve_ECDSA_CURVE_P512: + out.Curve = crypto.EllipticCurveP512 + default: + out.Curve = crypto.EllipticCurveP256 + } + + return out +} + +func webKeyDetailsListToPb(list []query.WebKeyDetails) []*webkey.WebKey { + out := make([]*webkey.WebKey, len(list)) + for i := range list { + out[i] = webKeyDetailsToPb(&list[i]) + } + return out +} + +func webKeyDetailsToPb(details *query.WebKeyDetails) *webkey.WebKey { + out := &webkey.WebKey{ + Id: details.KeyID, + CreationDate: timestamppb.New(details.CreationDate), + ChangeDate: timestamppb.New(details.ChangeDate), + State: webKeyStateToPb(details.State), + } + + switch config := details.Config.(type) { + case *crypto.WebKeyRSAConfig: + out.Key = &webkey.WebKey_Rsa{ + Rsa: webKeyRSAConfigToPb(config), + } + case *crypto.WebKeyECDSAConfig: + out.Key = &webkey.WebKey_Ecdsa{ + Ecdsa: webKeyECDSAConfigToPb(config), + } + case *crypto.WebKeyED25519Config: + out.Key = &webkey.WebKey_Ed25519{ + Ed25519: new(webkey.ED25519), + } + } + + return out +} + +func webKeyStateToPb(state domain.WebKeyState) webkey.State { + switch state { + case domain.WebKeyStateUnspecified: + return webkey.State_STATE_UNSPECIFIED + case domain.WebKeyStateInitial: + return webkey.State_STATE_INITIAL + case domain.WebKeyStateActive: + return webkey.State_STATE_ACTIVE + case domain.WebKeyStateInactive: + return webkey.State_STATE_INACTIVE + case domain.WebKeyStateRemoved: + return webkey.State_STATE_REMOVED + default: + return webkey.State_STATE_UNSPECIFIED + } +} + +func webKeyRSAConfigToPb(config *crypto.WebKeyRSAConfig) *webkey.RSA { + out := new(webkey.RSA) + + switch config.Bits { + case crypto.RSABitsUnspecified: + out.Bits = webkey.RSABits_RSA_BITS_UNSPECIFIED + case crypto.RSABits2048: + out.Bits = webkey.RSABits_RSA_BITS_2048 + case crypto.RSABits3072: + out.Bits = webkey.RSABits_RSA_BITS_3072 + case crypto.RSABits4096: + out.Bits = webkey.RSABits_RSA_BITS_4096 + } + + switch config.Hasher { + case crypto.RSAHasherUnspecified: + out.Hasher = webkey.RSAHasher_RSA_HASHER_UNSPECIFIED + case crypto.RSAHasherSHA256: + out.Hasher = webkey.RSAHasher_RSA_HASHER_SHA256 + case crypto.RSAHasherSHA384: + out.Hasher = webkey.RSAHasher_RSA_HASHER_SHA384 + case crypto.RSAHasherSHA512: + out.Hasher = webkey.RSAHasher_RSA_HASHER_SHA512 + } + + return out +} + +func webKeyECDSAConfigToPb(config *crypto.WebKeyECDSAConfig) *webkey.ECDSA { + out := new(webkey.ECDSA) + + switch config.Curve { + case crypto.EllipticCurveUnspecified: + out.Curve = webkey.ECDSACurve_ECDSA_CURVE_UNSPECIFIED + case crypto.EllipticCurveP256: + out.Curve = webkey.ECDSACurve_ECDSA_CURVE_P256 + case crypto.EllipticCurveP384: + out.Curve = webkey.ECDSACurve_ECDSA_CURVE_P384 + case crypto.EllipticCurveP512: + out.Curve = webkey.ECDSACurve_ECDSA_CURVE_P512 + } + + return out +} diff --git a/internal/api/grpc/webkey/v2/webkey_converter_test.go b/internal/api/grpc/webkey/v2/webkey_converter_test.go new file mode 100644 index 0000000000..e7387d96ad --- /dev/null +++ b/internal/api/grpc/webkey/v2/webkey_converter_test.go @@ -0,0 +1,494 @@ +package webkey + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" +) + +func Test_createWebKeyRequestToConfig(t *testing.T) { + type args struct { + req *webkey.CreateWebKeyRequest + } + tests := []struct { + name string + args args + want crypto.WebKeyConfig + }{ + { + name: "RSA", + args: args{&webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_3072, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA384, + }, + }, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }, + { + name: "ECDSA", + args: args{&webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Ecdsa{ + Ecdsa: &webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P384, + }, + }, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + { + name: "ED25519", + args: args{&webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Ed25519{ + Ed25519: &webkey.ED25519{}, + }, + }}, + want: &crypto.WebKeyED25519Config{}, + }, + { + name: "default", + args: args{&webkey.CreateWebKeyRequest{}}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := createWebKeyRequestToConfig(tt.args.req) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyRSAConfigToCrypto(t *testing.T) { + type args struct { + config *webkey.RSA + } + tests := []struct { + name string + args args + want *crypto.WebKeyRSAConfig + }{ + { + name: "unspecified", + args: args{&webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_UNSPECIFIED, + Hasher: webkey.RSAHasher_RSA_HASHER_UNSPECIFIED, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + { + name: "2048, RSA256", + args: args{&webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + { + name: "3072, RSA384", + args: args{&webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_3072, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA384, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }, + { + name: "4096, RSA512", + args: args{&webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_4096, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA512, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits4096, + Hasher: crypto.RSAHasherSHA512, + }, + }, + { + name: "invalid", + args: args{&webkey.RSA{ + Bits: 99, + Hasher: 99, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rsaToCrypto(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyECDSAConfigToCrypto(t *testing.T) { + type args struct { + config *webkey.ECDSA + } + tests := []struct { + name string + args args + want *crypto.WebKeyECDSAConfig + }{ + { + name: "unspecified", + args: args{&webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_UNSPECIFIED, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }, + }, + { + name: "P256", + args: args{&webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P256, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }, + }, + { + name: "P384", + args: args{&webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P384, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + { + name: "P512", + args: args{&webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P512, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP512, + }, + }, + { + name: "invalid", + args: args{&webkey.ECDSA{ + Curve: 99, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ecdsaToCrypto(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyDetailsListToPb(t *testing.T) { + list := []query.WebKeyDetails{ + { + KeyID: "key1", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }, + { + KeyID: "key2", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyED25519Config{}, + }, + } + want := []*webkey.WebKey{ + { + Id: "key1", + CreationDate: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + ChangeDate: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + State: webkey.State_STATE_ACTIVE, + Key: &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_3072, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA384, + }, + }, + }, + { + Id: "key2", + CreationDate: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + ChangeDate: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + State: webkey.State_STATE_ACTIVE, + Key: &webkey.WebKey_Ed25519{ + Ed25519: &webkey.ED25519{}, + }, + }, + } + got := webKeyDetailsListToPb(list) + assert.Equal(t, want, got) +} + +func Test_webKeyDetailsToPb(t *testing.T) { + type args struct { + details *query.WebKeyDetails + } + tests := []struct { + name string + args args + want *webkey.WebKey + }{ + { + name: "RSA", + args: args{&query.WebKeyDetails{ + KeyID: "keyID", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }}, + want: &webkey.WebKey{ + Id: "keyID", + CreationDate: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + ChangeDate: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + State: webkey.State_STATE_ACTIVE, + Key: &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_3072, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA384, + }, + }, + }, + }, + { + name: "ECDSA", + args: args{&query.WebKeyDetails{ + KeyID: "keyID", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }}, + want: &webkey.WebKey{ + Id: "keyID", + CreationDate: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + ChangeDate: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + State: webkey.State_STATE_ACTIVE, + Key: &webkey.WebKey_Ecdsa{ + Ecdsa: &webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P384, + }, + }, + }, + }, + { + name: "ED25519", + args: args{&query.WebKeyDetails{ + KeyID: "keyID", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyED25519Config{}, + }}, + want: &webkey.WebKey{ + Id: "keyID", + CreationDate: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + ChangeDate: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + State: webkey.State_STATE_ACTIVE, + Key: &webkey.WebKey_Ed25519{ + Ed25519: &webkey.ED25519{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyDetailsToPb(tt.args.details) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyStateToPb(t *testing.T) { + type args struct { + state domain.WebKeyState + } + tests := []struct { + name string + args args + want webkey.State + }{ + { + name: "unspecified", + args: args{domain.WebKeyStateUnspecified}, + want: webkey.State_STATE_UNSPECIFIED, + }, + { + name: "initial", + args: args{domain.WebKeyStateInitial}, + want: webkey.State_STATE_INITIAL, + }, + { + name: "active", + args: args{domain.WebKeyStateActive}, + want: webkey.State_STATE_ACTIVE, + }, + { + name: "inactive", + args: args{domain.WebKeyStateInactive}, + want: webkey.State_STATE_INACTIVE, + }, + { + name: "removed", + args: args{domain.WebKeyStateRemoved}, + want: webkey.State_STATE_REMOVED, + }, + { + name: "invalid", + args: args{99}, + want: webkey.State_STATE_UNSPECIFIED, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyStateToPb(tt.args.state) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyRSAConfigToPb(t *testing.T) { + type args struct { + config *crypto.WebKeyRSAConfig + } + tests := []struct { + name string + args args + want *webkey.RSA + }{ + { + name: "2048, RSA256", + args: args{&crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }}, + want: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, + { + name: "3072, RSA384", + args: args{&crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }}, + want: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_3072, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA384, + }, + }, + { + name: "4096, RSA512", + args: args{&crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits4096, + Hasher: crypto.RSAHasherSHA512, + }}, + want: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_4096, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA512, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyRSAConfigToPb(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyECDSAConfigToPb(t *testing.T) { + type args struct { + config *crypto.WebKeyECDSAConfig + } + tests := []struct { + name string + args args + want *webkey.ECDSA + }{ + { + name: "P256", + args: args{&crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }}, + want: &webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P256, + }, + }, + { + name: "P384", + args: args{&crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }}, + want: &webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P384, + }, + }, + { + name: "P512", + args: args{&crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP512, + }}, + want: &webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P512, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyECDSAConfigToPb(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go b/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go index 002669c233..0cbf629b43 100644 --- a/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go +++ b/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go @@ -12,11 +12,9 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/feature/v2" webkey "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" ) @@ -33,34 +31,8 @@ func TestMain(m *testing.M) { }()) } -func TestServer_Feature_Disabled(t *testing.T) { - instance, iamCtx, _ := createInstance(t, false) - client := instance.Client.WebKeyV2Beta - - t.Run("CreateWebKey", func(t *testing.T) { - _, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{}) - assertFeatureDisabledError(t, err) - }) - t.Run("ActivateWebKey", func(t *testing.T) { - _, err := client.ActivateWebKey(iamCtx, &webkey.ActivateWebKeyRequest{ - Id: "1", - }) - assertFeatureDisabledError(t, err) - }) - t.Run("DeleteWebKey", func(t *testing.T) { - _, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ - Id: "1", - }) - assertFeatureDisabledError(t, err) - }) - t.Run("ListWebKeys", func(t *testing.T) { - _, err := client.ListWebKeys(iamCtx, &webkey.ListWebKeysRequest{}) - assertFeatureDisabledError(t, err) - }) -} - func TestServer_ListWebKeys(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) // After the feature is first enabled, we can expect 2 generated keys with the default config. checkWebKeyListState(iamCtx, t, instance, 2, "", &webkey.WebKey_Rsa{ Rsa: &webkey.RSA{ @@ -71,7 +43,7 @@ func TestServer_ListWebKeys(t *testing.T) { } func TestServer_CreateWebKey(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) client := instance.Client.WebKeyV2Beta _, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ @@ -93,7 +65,7 @@ func TestServer_CreateWebKey(t *testing.T) { } func TestServer_ActivateWebKey(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) client := instance.Client.WebKeyV2Beta resp, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ @@ -120,7 +92,7 @@ func TestServer_ActivateWebKey(t *testing.T) { } func TestServer_DeleteWebKey(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) client := instance.Client.WebKeyV2Beta keyIDs := make([]string, 2) @@ -197,40 +169,22 @@ func TestServer_DeleteWebKey(t *testing.T) { }, creationDate) } -func createInstance(t *testing.T, enableFeature bool) (*integration.Instance, context.Context, *timestamppb.Timestamp) { +func createInstance(t *testing.T) (*integration.Instance, context.Context, *timestamppb.Timestamp) { instance := integration.NewInstance(CTX) creationDate := timestamppb.Now() iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - if enableFeature { - _, err := instance.Client.FeatureV2.SetInstanceFeatures(iamCTX, &feature.SetInstanceFeaturesRequest{ - WebKey: proto.Bool(true), - }) - require.NoError(t, err) - } - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamCTX, time.Minute) assert.EventuallyWithT(t, func(collect *assert.CollectT) { resp, err := instance.Client.WebKeyV2Beta.ListWebKeys(iamCTX, &webkey.ListWebKeysRequest{}) - if enableFeature { - assert.NoError(collect, err) - assert.Len(collect, resp.GetWebKeys(), 2) - } else { - assert.Error(collect, err) - } + assert.NoError(collect, err) + assert.Len(collect, resp.GetWebKeys(), 2) + }, retryDuration, tick) return instance, iamCTX, creationDate } -func assertFeatureDisabledError(t *testing.T, err error) { - t.Helper() - require.Error(t, err) - s := status.Convert(err) - assert.Equal(t, codes.FailedPrecondition, s.Code()) - assert.Contains(t, s.Message(), "WEBKEY-Ohx6E") -} - func checkWebKeyListState(ctx context.Context, t *testing.T, instance *integration.Instance, nKeys int, expectActiveKeyID string, config any, creationDate *timestamppb.Timestamp) { t.Helper() diff --git a/internal/api/grpc/webkey/v2beta/server.go b/internal/api/grpc/webkey/v2beta/server.go index 0d4ddb19c8..b000e98104 100644 --- a/internal/api/grpc/webkey/v2beta/server.go +++ b/internal/api/grpc/webkey/v2beta/server.go @@ -1,17 +1,22 @@ package webkey import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + protoreflect "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/query" webkey "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta/webkeyconnect" ) +var _ webkeyconnect.WebKeyServiceHandler = (*Server)(nil) + type Server struct { - webkey.UnimplementedWebKeyServiceServer command *command.Commands query *query.Queries } @@ -26,10 +31,6 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - webkey.RegisterWebKeyServiceServer(grpcServer, s) -} - func (s *Server) AppName() string { return webkey.WebKeyService_ServiceDesc.ServiceName } @@ -45,3 +46,11 @@ func (s *Server) AuthMethods() authz.MethodMapping { func (s *Server) RegisterGateway() server.RegisterGatewayFunc { return webkey.RegisterWebKeyServiceHandler } + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return webkeyconnect.NewWebKeyServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return webkey.File_zitadel_webkey_v2beta_webkey_service_proto +} diff --git a/internal/api/grpc/webkey/v2beta/webkey.go b/internal/api/grpc/webkey/v2beta/webkey.go index d45288dff2..fa37cc32e3 100644 --- a/internal/api/grpc/webkey/v2beta/webkey.go +++ b/internal/api/grpc/webkey/v2beta/webkey.go @@ -3,57 +3,47 @@ package webkey import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" webkey "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" ) -func (s *Server) CreateWebKey(ctx context.Context, req *webkey.CreateWebKeyRequest) (_ *webkey.CreateWebKeyResponse, err error) { +func (s *Server) CreateWebKey(ctx context.Context, req *connect.Request[webkey.CreateWebKeyRequest]) (_ *connect.Response[webkey.CreateWebKeyResponse], err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } - webKey, err := s.command.CreateWebKey(ctx, createWebKeyRequestToConfig(req)) + webKey, err := s.command.CreateWebKey(ctx, createWebKeyRequestToConfig(req.Msg)) if err != nil { return nil, err } - return &webkey.CreateWebKeyResponse{ + return connect.NewResponse(&webkey.CreateWebKeyResponse{ Id: webKey.KeyID, CreationDate: timestamppb.New(webKey.ObjectDetails.EventDate), - }, nil + }), nil } -func (s *Server) ActivateWebKey(ctx context.Context, req *webkey.ActivateWebKeyRequest) (_ *webkey.ActivateWebKeyResponse, err error) { +func (s *Server) ActivateWebKey(ctx context.Context, req *connect.Request[webkey.ActivateWebKeyRequest]) (_ *connect.Response[webkey.ActivateWebKeyResponse], err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } - details, err := s.command.ActivateWebKey(ctx, req.GetId()) + details, err := s.command.ActivateWebKey(ctx, req.Msg.GetId()) if err != nil { return nil, err } - return &webkey.ActivateWebKeyResponse{ + return connect.NewResponse(&webkey.ActivateWebKeyResponse{ ChangeDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) DeleteWebKey(ctx context.Context, req *webkey.DeleteWebKeyRequest) (_ *webkey.DeleteWebKeyResponse, err error) { +func (s *Server) DeleteWebKey(ctx context.Context, req *connect.Request[webkey.DeleteWebKeyRequest]) (_ *connect.Response[webkey.DeleteWebKeyResponse], err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } - deletedAt, err := s.command.DeleteWebKey(ctx, req.GetId()) + deletedAt, err := s.command.DeleteWebKey(ctx, req.Msg.GetId()) if err != nil { return nil, err } @@ -62,31 +52,21 @@ func (s *Server) DeleteWebKey(ctx context.Context, req *webkey.DeleteWebKeyReque if !deletedAt.IsZero() { deletionDate = timestamppb.New(deletedAt) } - return &webkey.DeleteWebKeyResponse{ + return connect.NewResponse(&webkey.DeleteWebKeyResponse{ DeletionDate: deletionDate, - }, nil + }), nil } -func (s *Server) ListWebKeys(ctx context.Context, _ *webkey.ListWebKeysRequest) (_ *webkey.ListWebKeysResponse, err error) { +func (s *Server) ListWebKeys(ctx context.Context, _ *connect.Request[webkey.ListWebKeysRequest]) (_ *connect.Response[webkey.ListWebKeysResponse], err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } list, err := s.query.ListWebKeys(ctx) if err != nil { return nil, err } - return &webkey.ListWebKeysResponse{ + return connect.NewResponse(&webkey.ListWebKeysResponse{ WebKeys: webKeyDetailsListToPb(list), - }, nil -} - -func checkWebKeyFeature(ctx context.Context) error { - if !authz.GetFeatures(ctx).WebKey { - return zerrors.ThrowPreconditionFailed(nil, "WEBKEY-Ohx6E", "Errors.WebKey.FeatureDisabled") - } - return nil + }), nil } diff --git a/internal/api/oidc/access_token.go b/internal/api/oidc/access_token.go index 2f2880efc2..08337bb5af 100644 --- a/internal/api/oidc/access_token.go +++ b/internal/api/oidc/access_token.go @@ -11,7 +11,6 @@ import ( "github.com/zitadel/oidc/v3/pkg/op" "golang.org/x/text/language" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" @@ -53,7 +52,7 @@ func (s *Server) verifyAccessToken(ctx context.Context, tkn string) (_ *accessTo tokenID, subject = split[0], split[1] } else { verifier := op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), s.accessTokenKeySet, - op.WithSupportedAccessTokenSigningAlgorithms(supportedSigningAlgs(ctx)...), + op.WithSupportedAccessTokenSigningAlgorithms(supportedSigningAlgs()...), ) claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, tkn, verifier) if err != nil { @@ -122,7 +121,7 @@ func (s *Server) assertClientScopesForPAT(ctx context.Context, token *accessToke if err != nil { return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") } - roles, err := s.query.SearchProjectRoles(ctx, authz.GetFeatures(ctx).TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) + roles, err := s.query.SearchProjectRoles(ctx, false, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) if err != nil { return err } diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index da0e084877..b29e157fc2 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -24,7 +24,6 @@ import ( "github.com/zitadel/zitadel/internal/domain/federatedlogout" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/user/model" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -216,11 +215,11 @@ func (o *OPStorage) SaveAuthCode(ctx context.Context, id, code string) (err erro return o.repo.SaveAuthCode(ctx, id, code, userAgentID) } -func (o *OPStorage) DeleteAuthRequest(ctx context.Context, id string) (err error) { +func (o *OPStorage) DeleteAuthRequest(context.Context, string) error { panic(o.panicErr("DeleteAuthRequest")) } -func (o *OPStorage) CreateAccessToken(ctx context.Context, req op.TokenRequest) (string, time.Time, error) { +func (o *OPStorage) CreateAccessToken(context.Context, op.TokenRequest) (string, time.Time, error) { panic(o.panicErr("CreateAccessToken")) } @@ -492,34 +491,6 @@ func (o *OPStorage) GetRefreshTokenInfo(ctx context.Context, clientID string, to return refreshToken.UserID, refreshToken.ID, nil } -func (o *OPStorage) assertProjectRoleScopes(ctx context.Context, clientID string, scopes []string) ([]string, error) { - for _, scope := range scopes { - if strings.HasPrefix(scope, ScopeProjectRolePrefix) { - return scopes, nil - } - } - - project, err := o.query.ProjectByOIDCClientID(ctx, clientID) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(nil, "OIDC-w4wIn", "Errors.Internal") - } - if !project.ProjectRoleAssertion { - return scopes, nil - } - projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(project.ID) - if err != nil { - return nil, zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") - } - roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) - if err != nil { - return nil, err - } - for _, role := range roles.ProjectRoles { - scopes = append(scopes, ScopeProjectRolePrefix+role.Key) - } - return scopes, nil -} - func (o *OPStorage) assertProjectRoleScopesByProject(ctx context.Context, project *query.Project, scopes []string) ([]string, error) { for _, scope := range scopes { if strings.HasPrefix(scope, ScopeProjectRolePrefix) { @@ -543,22 +514,6 @@ func (o *OPStorage) assertProjectRoleScopesByProject(ctx context.Context, projec return scopes, nil } -func (o *OPStorage) assertClientScopesForPAT(ctx context.Context, token *model.TokenView, clientID, projectID string) error { - token.Audience = append(token.Audience, clientID) - projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(projectID) - if err != nil { - return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") - } - roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) - if err != nil { - return err - } - for _, role := range roles.ProjectRoles { - token.Scopes = append(token.Scopes, ScopeProjectRolePrefix+role.Key) - } - return nil -} - func setContextUserSystem(ctx context.Context) context.Context { data := authz.CtxData{ UserID: "SYSTEM", diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go index 2144ca8ba1..064af20de0 100644 --- a/internal/api/oidc/auth_request_converter.go +++ b/internal/api/oidc/auth_request_converter.go @@ -140,13 +140,8 @@ func HttpHeadersFromContext(ctx context.Context) (userAgent, acceptLang string) if !ok { return } - if agents, ok := ctxHeaders[http_utils.UserAgentHeader]; ok { - userAgent = agents[0] - } - if langs, ok := ctxHeaders[http_utils.AcceptLanguage]; ok { - acceptLang = langs[0] - } - return userAgent, acceptLang + return ctxHeaders.Get(http_utils.UserAgentHeader), + ctxHeaders.Get(http_utils.AcceptLanguage) } func IpFromContext(ctx context.Context) net.IP { diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index 08ed8c31b9..6a2639f213 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -2,25 +2,17 @@ package oidc import ( "context" - "encoding/base64" "encoding/json" - "fmt" "slices" "strings" "time" - "github.com/dop251/goja" "github.com/go-jose/go-jose/v4" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/zitadel/internal/actions" - "github.com/zitadel/zitadel/internal/actions/object" "github.com/zitadel/zitadel/internal/api/authz" - api_http "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -44,6 +36,9 @@ const ( oidcCtx = "oidc" ) +// GetClientByClientID implements the op.Storage interface to retrieve an OIDC client by its ID. +// +// TODO: Still used for Auth request creation for v1 login. func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Client, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { @@ -57,819 +52,44 @@ func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Cl return ClientFromBusiness(client, o.defaultLoginURL, o.defaultLoginURLV2), nil } -func (o *OPStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (_ *jose.JSONWebKey, err error) { - return o.GetKeyByIDAndIssuer(ctx, keyID, userID) +func (o *OPStorage) GetKeyByIDAndClientID(context.Context, string, string) (*jose.JSONWebKey, error) { + panic(o.panicErr("GetKeyByIDAndClientID")) } -func (o *OPStorage) GetKeyByIDAndIssuer(ctx context.Context, keyID, issuer string) (_ *jose.JSONWebKey, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - publicKeyData, err := o.query.GetAuthNKeyPublicKeyByIDAndIdentifier(ctx, keyID, issuer) - if err != nil { - return nil, err - } - publicKey, err := crypto.BytesToPublicKey(publicKeyData) - if err != nil { - return nil, err - } - return &jose.JSONWebKey{ - KeyID: keyID, - Use: "sig", - Key: publicKey, - }, nil +func (o *OPStorage) ValidateJWTProfileScopes(context.Context, string, []string) ([]string, error) { + panic(o.panicErr("ValidateJWTProfileScopes")) } -func (o *OPStorage) ValidateJWTProfileScopes(ctx context.Context, subject string, scopes []string) (_ []string, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - user, err := o.query.GetUserByID(ctx, true, subject) - if err != nil { - return nil, err - } - return o.checkOrgScopes(ctx, user, scopes) +func (o *OPStorage) AuthorizeClientIDSecret(context.Context, string, string) error { + panic(o.panicErr("AuthorizeClientIDSecret")) } -func (o *OPStorage) AuthorizeClientIDSecret(ctx context.Context, id string, secret string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - ctx = authz.SetCtxData(ctx, authz.CtxData{ - UserID: oidcCtx, - OrgID: oidcCtx, - }) - app, err := o.query.AppByClientID(ctx, id) - if err != nil { - return err - } - if app.OIDCConfig != nil { - return o.command.VerifyOIDCClientSecret(ctx, app.ProjectID, app.ID, secret) - } - return o.command.VerifyAPIClientSecret(ctx, app.ProjectID, app.ID, secret) +func (o *OPStorage) SetUserinfoFromToken(context.Context, *oidc.UserInfo, string, string, string) error { + panic(o.panicErr("SetUserinfoFromToken")) } -func (o *OPStorage) SetUserinfoFromToken(ctx context.Context, userInfo *oidc.UserInfo, tokenID, subject, origin string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - if strings.HasPrefix(tokenID, command.IDPrefixV2) { - token, err := o.query.ActiveAccessTokenByToken(ctx, tokenID) - if err != nil { - return err - } - if err = o.isOriginAllowed(ctx, token.ClientID, origin); err != nil { - return err - } - return o.setUserinfo(ctx, userInfo, token.UserID, token.ClientID, token.Scope, nil) - } - - token, err := o.repo.TokenByIDs(ctx, subject, tokenID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired") - } - if token.ApplicationID != "" { - if err = o.isOriginAllowed(ctx, token.ApplicationID, origin); err != nil { - return err - } - } - return o.setUserinfo(ctx, userInfo, token.UserID, token.ApplicationID, token.Scopes, nil) +func (o *OPStorage) SetUserinfoFromScopes(context.Context, *oidc.UserInfo, string, string, []string) error { + panic(o.panicErr("SetUserinfoFromScopes")) } -func (o *OPStorage) SetUserinfoFromScopes(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - if applicationID != "" { - app, err := o.query.AppByOIDCClientID(ctx, applicationID) - if err != nil { - return err - } - if app.OIDCConfig.AssertIDTokenRole { - scopes, err = o.assertProjectRoleScopes(ctx, applicationID, scopes) - if err != nil { - return zerrors.ThrowPreconditionFailed(err, "OIDC-Dfe2s", "Errors.Internal") - } - } - } - return o.setUserinfo(ctx, userInfo, userID, applicationID, scopes, nil) +func (o *OPStorage) SetUserinfoFromRequest(context.Context, *oidc.UserInfo, op.IDTokenRequest, []string) error { + panic(o.panicErr("SetUserinfoFromRequest")) } -// SetUserinfoFromRequest extends the SetUserinfoFromScopes during the id_token generation. -// This is required for V2 tokens to be able to set the sessionID (`sid`) claim. -func (o *OPStorage) SetUserinfoFromRequest(ctx context.Context, userinfo *oidc.UserInfo, request op.IDTokenRequest, _ []string) error { - switch t := request.(type) { - case *AuthRequestV2: - userinfo.AppendClaims("sid", t.SessionID) - case *RefreshTokenRequestV2: - userinfo.AppendClaims("sid", t.SessionID) - } - return nil +func (o *OPStorage) SetIntrospectionFromToken(context.Context, *oidc.IntrospectionResponse, string, string, string) error { + panic(o.panicErr("SetIntrospectionFromToken")) } -func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - if strings.HasPrefix(tokenID, command.IDPrefixV2) { - token, err := o.query.ActiveAccessTokenByToken(ctx, tokenID) - if err != nil { - return err - } - projectID, err := o.query.ProjectIDFromClientID(ctx, clientID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") - } - return o.introspect(ctx, introspection, - tokenID, token.UserID, token.ClientID, clientID, projectID, - token.Audience, token.Scope, - token.AccessTokenCreation, token.AccessTokenExpiration) - } - - token, err := o.repo.TokenByIDs(ctx, subject, tokenID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired") - } - projectID, err := o.query.ProjectIDFromClientID(ctx, clientID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") - } - if token.IsPAT { - err = o.assertClientScopesForPAT(ctx, token, clientID, projectID) - if err != nil { - return zerrors.ThrowPreconditionFailed(err, "OIDC-AGefw", "Errors.Internal") - } - } - return o.introspect(ctx, introspection, - token.ID, token.UserID, token.ApplicationID, clientID, projectID, - token.Audience, token.Scopes, - token.CreationDate, token.Expiration) +func (o *OPStorage) ClientCredentialsTokenRequest(context.Context, string, []string) (op.TokenRequest, error) { + panic(o.panicErr("ClientCredentialsTokenRequest")) } -func (o *OPStorage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scope []string) (_ op.TokenRequest, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - user, err := o.query.GetUserByLoginName(ctx, false, clientID) - if err != nil { - return nil, err - } - scope, err = o.checkOrgScopes(ctx, user, scope) - if err != nil { - return nil, err - } - audience := domain.AddAudScopeToAudience(ctx, nil, scope) - return &clientCredentialsRequest{ - sub: user.ID, - scopes: scope, - audience: audience, - }, nil -} - -// ClientCredentials method is kept to keep the storage interface implemented. -// However, it should never be called as the VerifyClient method on the Server is overridden. func (o *OPStorage) ClientCredentials(context.Context, string, string) (op.Client, error) { - return nil, zerrors.ThrowInternal(nil, "OIDC-Su8So", "Errors.Internal") + panic(o.panicErr("ClientCredentials")) } -// isOriginAllowed checks whether a call by the client to the endpoint is allowed from the provided origin -// if no origin is provided, no error will be returned -func (o *OPStorage) isOriginAllowed(ctx context.Context, clientID, origin string) error { - if origin == "" { - return nil - } - app, err := o.query.AppByOIDCClientID(ctx, clientID) - if err != nil { - return err - } - if api_http.IsOriginAllowed(app.OIDCConfig.AllowedOrigins, origin) { - return nil - } - return zerrors.ThrowPermissionDenied(nil, "OIDC-da1f3", "origin is not allowed") -} - -func (o *OPStorage) introspect( - ctx context.Context, - introspection *oidc.IntrospectionResponse, - tokenID, subject, tokenClientID, introspectionClientID, introspectionProjectID string, - audience, scope []string, - tokenCreation, tokenExpiration time.Time, -) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - for _, aud := range audience { - if aud == introspectionClientID || aud == introspectionProjectID { - userInfo := new(oidc.UserInfo) - err = o.setUserinfo(ctx, userInfo, subject, introspectionClientID, scope, []string{introspectionProjectID}) - if err != nil { - return err - } - introspection.SetUserInfo(userInfo) - introspection.Scope = scope - introspection.ClientID = tokenClientID - introspection.TokenType = oidc.BearerToken - introspection.Expiration = oidc.FromTime(tokenExpiration) - introspection.IssuedAt = oidc.FromTime(tokenCreation) - introspection.NotBefore = oidc.FromTime(tokenCreation) - introspection.Audience = audience - introspection.Issuer = op.IssuerFromContext(ctx) - introspection.JWTID = tokenID - return nil - } - } - return zerrors.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client") -} - -func (o *OPStorage) checkOrgScopes(ctx context.Context, user *query.User, scopes []string) ([]string, error) { - for i := len(scopes) - 1; i >= 0; i-- { - scope := scopes[i] - if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) { - var orgID string - org, err := o.query.OrgByPrimaryDomain(ctx, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope)) - if err == nil { - orgID = org.ID - } - if orgID != user.ResourceOwner { - scopes[i] = scopes[len(scopes)-1] - scopes[len(scopes)-1] = "" - scopes = scopes[:len(scopes)-1] - } - } - if strings.HasPrefix(scope, domain.OrgIDScope) { - if strings.TrimPrefix(scope, domain.OrgIDScope) != user.ResourceOwner { - scopes[i] = scopes[len(scopes)-1] - scopes[len(scopes)-1] = "" - scopes = scopes[:len(scopes)-1] - } - } - } - return scopes, nil -} - -func (o *OPStorage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string, roleAudience []string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - user, err := o.query.GetUserByID(ctx, true, userID) - if err != nil { - return err - } - if user.State != domain.UserStateActive { - return zerrors.ThrowUnauthenticated(nil, "OIDC-S3tha", "Errors.Users.NotActive") - } - var allRoles bool - roles := make([]string, 0) - for _, scope := range scopes { - switch scope { - case oidc.ScopeOpenID: - userInfo.Subject = user.ID - case oidc.ScopeEmail: - setUserInfoEmail(userInfo, user) - case oidc.ScopeProfile: - o.setUserInfoProfile(ctx, userInfo, user) - case oidc.ScopePhone: - setUserInfoPhone(userInfo, user) - case oidc.ScopeAddress: - //TODO: handle address for human users as soon as implemented - case ScopeUserMetaData: - if err := o.setUserInfoMetadata(ctx, userInfo, userID); err != nil { - return err - } - case ScopeResourceOwner: - if err := o.setUserInfoResourceOwner(ctx, userInfo, userID); err != nil { - return err - } - case ScopeProjectsRoles: - allRoles = true - default: - if strings.HasPrefix(scope, ScopeProjectRolePrefix) { - roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix)) - } - if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) { - userInfo.AppendClaims(domain.OrgDomainPrimaryClaim, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope)) - } - if strings.HasPrefix(scope, domain.OrgIDScope) { - userInfo.AppendClaims(domain.OrgIDClaim, strings.TrimPrefix(scope, domain.OrgIDScope)) - if err := o.setUserInfoResourceOwner(ctx, userInfo, userID); err != nil { - return err - } - } - } - } - - // if all roles are requested take the audience for those from the scopes - if allRoles && len(roleAudience) == 0 { - roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scopes) - } - - userGrants, projectRoles, err := o.assertRoles(ctx, userID, applicationID, roles, roleAudience) - if err != nil { - return err - } - o.setUserInfoRoleClaims(userInfo, projectRoles) - - return o.userinfoFlows(ctx, user, userGrants, userInfo) -} - -func (o *OPStorage) setUserInfoProfile(ctx context.Context, userInfo *oidc.UserInfo, user *query.User) { - userInfo.PreferredUsername = user.PreferredLoginName - userInfo.UpdatedAt = oidc.FromTime(user.ChangeDate) - if user.Machine != nil { - userInfo.Name = user.Machine.Name - return - } - userInfo.Name = user.Human.DisplayName - userInfo.FamilyName = user.Human.LastName - userInfo.GivenName = user.Human.FirstName - userInfo.Nickname = user.Human.NickName - userInfo.Gender = getGender(user.Human.Gender) - userInfo.Locale = oidc.NewLocale(user.Human.PreferredLanguage) - userInfo.Picture = domain.AvatarURL(o.assetAPIPrefix(ctx), user.ResourceOwner, user.Human.AvatarKey) -} - -func setUserInfoEmail(userInfo *oidc.UserInfo, user *query.User) { - if user.Human == nil { - return - } - userInfo.UserInfoEmail = oidc.UserInfoEmail{ - Email: string(user.Human.Email), - EmailVerified: oidc.Bool(user.Human.IsEmailVerified)} -} - -func setUserInfoPhone(userInfo *oidc.UserInfo, user *query.User) { - if user.Human == nil { - return - } - userInfo.UserInfoPhone = oidc.UserInfoPhone{ - PhoneNumber: string(user.Human.Phone), - PhoneNumberVerified: user.Human.IsPhoneVerified, - } -} - -func (o *OPStorage) setUserInfoMetadata(ctx context.Context, userInfo *oidc.UserInfo, userID string) error { - userMetaData, err := o.assertUserMetaData(ctx, userID) - if err != nil { - return err - } - if len(userMetaData) > 0 { - userInfo.AppendClaims(ClaimUserMetaData, userMetaData) - } - return nil -} - -func (o *OPStorage) setUserInfoResourceOwner(ctx context.Context, userInfo *oidc.UserInfo, userID string) error { - resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) - if err != nil { - return err - } - for claim, value := range resourceOwnerClaims { - userInfo.AppendClaims(claim, value) - } - return nil -} - -func (o *OPStorage) setUserInfoRoleClaims(userInfo *oidc.UserInfo, roles *projectsRoles) { - if roles != nil && len(roles.projects) > 0 { - if roles, ok := roles.projects[roles.requestProjectID]; ok { - userInfo.AppendClaims(ClaimProjectRoles, roles) - } - for projectID, roles := range roles.projects { - userInfo.AppendClaims(fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles) - } - } -} - -func (o *OPStorage) userinfoFlows(ctx context.Context, user *query.User, userGrants *query.UserGrants, userInfo *oidc.UserInfo) error { - queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreUserinfoCreation, user.ResourceOwner) - if err != nil { - return err - } - - ctxFields := actions.SetContextFields( - actions.SetFields("v1", - actions.SetFields("claims", userinfoClaims(userInfo)), - actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} { - return func(call goja.FunctionCall) goja.Value { - return object.UserFromQuery(c, user) - } - }), - actions.SetFields("user", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - resourceOwnerQuery, err := query.NewUserMetadataResourceOwnerSearchQuery(user.ResourceOwner) - if err != nil { - logging.WithError(err).Debug("unable to create search query") - panic(err) - } - metadata, err := o.query.SearchUserMetadata( - ctx, - true, - userInfo.Subject, - &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{resourceOwnerQuery}}, - false, - ) - if err != nil { - logging.WithError(err).Info("unable to get md in action") - panic(err) - } - return object.UserMetadataListFromQuery(c, metadata) - } - }), - actions.SetFields("grants", - func(c *actions.FieldConfig) interface{} { - return object.UserGrantsFromQuery(ctx, o.query, c, userGrants) - }, - ), - ), - actions.SetFields("org", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - return object.GetOrganizationMetadata(ctx, o.query, c, user.ResourceOwner) - } - }), - ), - ), - ) - - for _, action := range queriedActions { - actionCtx, cancel := context.WithTimeout(ctx, action.Timeout()) - claimLogs := []string{} - - apiFields := actions.WithAPIFields( - actions.SetFields("v1", - actions.SetFields("userinfo", - actions.SetFields("setClaim", func(key string, value interface{}) { - if strings.HasPrefix(key, ClaimPrefix) { - return - } - if userInfo.Claims[key] == nil { - userInfo.AppendClaims(key, value) - return - } - claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key)) - }), - actions.SetFields("appendLogIntoClaims", func(entry string) { - claimLogs = append(claimLogs, entry) - }), - ), - actions.SetFields("claims", - actions.SetFields("setClaim", func(key string, value interface{}) { - if strings.HasPrefix(key, ClaimPrefix) { - return - } - if userInfo.Claims[key] == nil { - userInfo.AppendClaims(key, value) - return - } - claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key)) - }), - actions.SetFields("appendLogIntoClaims", func(entry string) { - claimLogs = append(claimLogs, entry) - }), - ), - actions.SetFields("user", - actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) != 2 { - panic("exactly 2 (key, value) arguments expected") - } - key := call.Arguments[0].Export().(string) - val := call.Arguments[1].Export() - - value, err := json.Marshal(val) - if err != nil { - logging.WithError(err).Debug("unable to marshal") - panic(err) - } - - metadata := &domain.Metadata{ - Key: key, - Value: value, - } - if _, err = o.command.SetUserMetadata(ctx, metadata, userInfo.Subject, user.ResourceOwner); err != nil { - logging.WithError(err).Info("unable to set md in action") - panic(err) - } - return nil - }), - ), - ), - ) - - err = actions.Run( - actionCtx, - ctxFields, - apiFields, - action.Script, - action.Name, - append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., - ) - cancel() - if err != nil { - return err - } - if len(claimLogs) > 0 { - userInfo.AppendClaims(fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs) - } - } - - return nil -} - -func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - roles := make([]string, 0) - var allRoles bool - for _, scope := range scopes { - switch scope { - case ScopeUserMetaData: - userMetaData, err := o.assertUserMetaData(ctx, userID) - if err != nil { - return nil, err - } - if len(userMetaData) > 0 { - claims = appendClaim(claims, ClaimUserMetaData, userMetaData) - } - case ScopeResourceOwner: - resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) - if err != nil { - return nil, err - } - for claim, value := range resourceOwnerClaims { - claims = appendClaim(claims, claim, value) - } - case ScopeProjectsRoles: - allRoles = true - } - if strings.HasPrefix(scope, ScopeProjectRolePrefix) { - roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix)) - } - if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) { - claims = appendClaim(claims, domain.OrgDomainPrimaryClaim, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope)) - } - if strings.HasPrefix(scope, domain.OrgIDScope) { - claims = appendClaim(claims, domain.OrgIDClaim, strings.TrimPrefix(scope, domain.OrgIDScope)) - resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) - if err != nil { - return nil, err - } - for claim, value := range resourceOwnerClaims { - claims = appendClaim(claims, claim, value) - } - } - } - - // If requested, use the audience as context for the roles, - // otherwise the project itself will be used - var roleAudience []string - if allRoles { - roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scopes) - } - - userGrants, projectRoles, err := o.assertRoles(ctx, userID, clientID, roles, roleAudience) - if err != nil { - return nil, err - } - - if projectRoles != nil && len(projectRoles.projects) > 0 { - if roles, ok := projectRoles.projects[projectRoles.requestProjectID]; ok { - claims = appendClaim(claims, ClaimProjectRoles, roles) - } - for projectID, roles := range projectRoles.projects { - claims = appendClaim(claims, fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles) - } - } - - return o.privateClaimsFlows(ctx, userID, userGrants, claims) -} - -func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, userGrants *query.UserGrants, claims map[string]interface{}) (map[string]interface{}, error) { - user, err := o.query.GetUserByID(ctx, true, userID) - if err != nil { - return nil, err - } - queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreAccessTokenCreation, user.ResourceOwner) - if err != nil { - return nil, err - } - - ctxFields := actions.SetContextFields( - actions.SetFields("v1", - actions.SetFields("claims", func(c *actions.FieldConfig) interface{} { - return c.Runtime.ToValue(claims) - }), - actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} { - return func(call goja.FunctionCall) goja.Value { - return object.UserFromQuery(c, user) - } - }), - actions.SetFields("user", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - resourceOwnerQuery, err := query.NewUserMetadataResourceOwnerSearchQuery(user.ResourceOwner) - if err != nil { - logging.WithError(err).Debug("unable to create search query") - panic(err) - } - metadata, err := o.query.SearchUserMetadata( - ctx, - true, - userID, - &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{resourceOwnerQuery}}, - false, - ) - if err != nil { - logging.WithError(err).Info("unable to get md in action") - panic(err) - } - return object.UserMetadataListFromQuery(c, metadata) - } - }), - actions.SetFields("grants", func(c *actions.FieldConfig) interface{} { - return object.UserGrantsFromQuery(ctx, o.query, c, userGrants) - }), - ), - actions.SetFields("org", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - return object.GetOrganizationMetadata(ctx, o.query, c, user.ResourceOwner) - } - }), - ), - ), - ) - - for _, action := range queriedActions { - claimLogs := []string{} - actionCtx, cancel := context.WithTimeout(ctx, action.Timeout()) - - apiFields := actions.WithAPIFields( - actions.SetFields("v1", - actions.SetFields("claims", - actions.SetFields("setClaim", func(key string, value interface{}) { - if strings.HasPrefix(key, ClaimPrefix) { - return - } - if _, ok := claims[key]; !ok { - claims = appendClaim(claims, key, value) - return - } - claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key)) - }), - actions.SetFields("appendLogIntoClaims", func(entry string) { - claimLogs = append(claimLogs, entry) - }), - ), - actions.SetFields("user", - actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) != 2 { - panic("exactly 2 (key, value) arguments expected") - } - key := call.Arguments[0].Export().(string) - val := call.Arguments[1].Export() - - value, err := json.Marshal(val) - if err != nil { - logging.WithError(err).Debug("unable to marshal") - panic(err) - } - - metadata := &domain.Metadata{ - Key: key, - Value: value, - } - if _, err = o.command.SetUserMetadata(ctx, metadata, userID, user.ResourceOwner); err != nil { - logging.WithError(err).Info("unable to set md in action") - panic(err) - } - return nil - }), - ), - ), - ) - - err = actions.Run( - actionCtx, - ctxFields, - apiFields, - action.Script, - action.Name, - append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., - ) - cancel() - if err != nil { - return nil, err - } - if len(claimLogs) > 0 { - claims = appendClaim(claims, fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs) - claimLogs = nil - } - } - - return claims, nil -} - -func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles, roleAudience []string) (*query.UserGrants, *projectsRoles, error) { - if (applicationID == "" || len(requestedRoles) == 0) && len(roleAudience) == 0 { - return nil, nil, nil - } - projectID, err := o.query.ProjectIDFromClientID(ctx, applicationID) - // applicationID might contain a username (e.g. client credentials) -> ignore the not found - if err != nil && !zerrors.IsNotFound(err) { - return nil, nil, err - } - // ensure the projectID of the requesting is part of the roleAudience - if projectID != "" { - roleAudience = append(roleAudience, projectID) - } - projectQuery, err := query.NewUserGrantProjectIDsSearchQuery(roleAudience) - if err != nil { - return nil, nil, err - } - userIDQuery, err := query.NewUserGrantUserIDSearchQuery(userID) - if err != nil { - return nil, nil, err - } - activeQuery, err := query.NewUserGrantStateQuery(domain.UserGrantStateActive) - if err != nil { - return nil, nil, err - } - grants, err := o.query.UserGrants(ctx, &query.UserGrantsQueries{ - Queries: []query.SearchQuery{ - projectQuery, - userIDQuery, - activeQuery, - }, - }, true) - if err != nil { - return nil, nil, err - } - roles := new(projectsRoles) - // if specific roles where requested, check if they are granted and append them in the roles list - if len(requestedRoles) > 0 { - for _, requestedRole := range requestedRoles { - for _, grant := range grants.UserGrants { - checkGrantedRoles(roles, *grant, requestedRole, grant.ProjectID == projectID) - } - } - return grants, roles, nil - } - // no specific roles were requested, so convert any grants into roles - for _, grant := range grants.UserGrants { - for _, role := range grant.Roles { - roles.Add(grant.ProjectID, role, grant.ResourceOwner, grant.OrgPrimaryDomain, grant.ProjectID == projectID) - } - } - return grants, roles, nil -} - -func (o *OPStorage) assertUserMetaData(ctx context.Context, userID string) (map[string]string, error) { - metaData, err := o.query.SearchUserMetadata(ctx, true, userID, &query.UserMetadataSearchQueries{}, false) - if err != nil { - return nil, err - } - - userMetaData := make(map[string]string) - for _, md := range metaData.Metadata { - userMetaData[md.Key] = base64.RawURLEncoding.EncodeToString(md.Value) - } - return userMetaData, nil -} - -func (o *OPStorage) assertUserResourceOwner(ctx context.Context, userID string) (map[string]string, error) { - user, err := o.query.GetUserByID(ctx, true, userID) - if err != nil { - return nil, err - } - resourceOwner, err := o.query.OrgByID(ctx, true, user.ResourceOwner) - if err != nil { - return nil, err - } - return map[string]string{ - ClaimResourceOwnerID: resourceOwner.ID, - ClaimResourceOwnerName: resourceOwner.Name, - ClaimResourceOwnerPrimaryDomain: resourceOwner.Domain, - }, nil +func (o *OPStorage) GetPrivateClaimsFromScopes(context.Context, string, string, []string) (map[string]interface{}, error) { + panic(o.panicErr("GetPrivateClaimsFromScopes")) } func checkGrantedRoles(roles *projectsRoles, grant query.UserGrant, requestedRole string, isRequested bool) { @@ -946,14 +166,6 @@ func getGender(gender domain.Gender) oidc.Gender { return "" } -func appendClaim(claims map[string]interface{}, claim string, value interface{}) map[string]interface{} { - if claims == nil { - claims = make(map[string]interface{}) - } - claims[claim] = value - return claims -} - func userinfoClaims(userInfo *oidc.UserInfo) func(c *actions.FieldConfig) interface{} { return func(c *actions.FieldConfig) interface{} { marshalled, err := json.Marshal(userInfo) diff --git a/internal/api/oidc/client_credentials.go b/internal/api/oidc/client_credentials.go index 9087360452..c86f5da9ae 100644 --- a/internal/api/oidc/client_credentials.go +++ b/internal/api/oidc/client_credentials.go @@ -14,27 +14,6 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -type clientCredentialsRequest struct { - sub string - audience []string - scopes []string -} - -// GetSubject returns the subject for token to be created because of the client credentials request -// the subject will be the id of the service user -func (c *clientCredentialsRequest) GetSubject() string { - return c.sub -} - -// GetAudience returns the audience for token to be created because of the client credentials request -func (c *clientCredentialsRequest) GetAudience() []string { - return c.audience -} - -func (c *clientCredentialsRequest) GetScopes() []string { - return c.scopes -} - func (s *Server) clientCredentialsAuth(ctx context.Context, clientID, clientSecret string) (op.Client, error) { user, err := s.query.GetUserByLoginName(ctx, false, clientID) if zerrors.IsNotFound(err) { diff --git a/internal/api/oidc/device_auth.go b/internal/api/oidc/device_auth.go index a10cba499d..8912ad1736 100644 --- a/internal/api/oidc/device_auth.go +++ b/internal/api/oidc/device_auth.go @@ -88,6 +88,6 @@ func (o *OPStorage) StoreDeviceAuthorization(ctx context.Context, clientID, devi return err } -func (o *OPStorage) GetDeviceAuthorizatonState(ctx context.Context, _, deviceCode string) (state *op.DeviceAuthorizationState, err error) { - return nil, nil +func (o *OPStorage) GetDeviceAuthorizatonState(context.Context, string, string) (*op.DeviceAuthorizationState, error) { + panic(o.panicErr("GetDeviceAuthorizatonState")) } diff --git a/internal/api/oidc/integration_test/keys_test.go b/internal/api/oidc/integration_test/keys_test.go index 8b66e980d0..a6223cf1ee 100644 --- a/internal/api/oidc/integration_test/keys_test.go +++ b/internal/api/oidc/integration_test/keys_test.go @@ -14,12 +14,10 @@ import ( "github.com/stretchr/testify/require" "github.com/zitadel/oidc/v3/pkg/client" "github.com/zitadel/oidc/v3/pkg/oidc" - "google.golang.org/protobuf/proto" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/feature/v2" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) @@ -53,25 +51,16 @@ func TestServer_Keys(t *testing.T) { require.NoError(t, err) tests := []struct { - name string - webKeyFeature bool - wantLen int + name string + wantLen int }{ { - name: "legacy only", - webKeyFeature: false, - wantLen: 1, - }, - { - name: "webkeys with legacy", - webKeyFeature: true, - wantLen: 3, // 1 legacy + 2 created by enabling feature flag + name: "webkeys", + wantLen: 2, // 2 from instance creation. }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ensureWebKeyFeature(t, instance, tt.webKeyFeature) - assert.EventuallyWithT(t, func(ttt *assert.CollectT) { resp, err := http.Get(discovery.JwksURI) require.NoError(ttt, err) @@ -92,30 +81,10 @@ func TestServer_Keys(t *testing.T) { } cacheControl := resp.Header.Get("cache-control") - if tt.webKeyFeature { - require.Equal(ttt, "max-age=300, must-revalidate", cacheControl) - return - } - require.Equal(ttt, "no-store", cacheControl) + require.Equal(ttt, "max-age=300, must-revalidate", cacheControl) }, time.Minute, time.Second/10) }) } } - -func ensureWebKeyFeature(t *testing.T, instance *integration.Instance, set bool) { - ctxIam := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - - _, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{ - WebKey: proto.Bool(set), - }) - require.NoError(t, err) - - t.Cleanup(func() { - _, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{ - WebKey: proto.Bool(false), - }) - require.NoError(t, err) - }) -} diff --git a/internal/api/oidc/integration_test/token_exchange_test.go b/internal/api/oidc/integration_test/token_exchange_test.go index dcd2d61669..56a50a7c96 100644 --- a/internal/api/oidc/integration_test/token_exchange_test.go +++ b/internal/api/oidc/integration_test/token_exchange_test.go @@ -52,13 +52,6 @@ func setTokenExchangeFeature(t *testing.T, instance *integration.Instance, value time.Sleep(time.Second) } -func resetFeatures(t *testing.T, instance *integration.Instance) { - iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - _, err := instance.Client.FeatureV2.ResetInstanceFeatures(iamCTX, &feature.ResetInstanceFeaturesRequest{}) - require.NoError(t, err) - time.Sleep(time.Second) -} - func setImpersonationPolicy(t *testing.T, instance *integration.Instance, value bool) { iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/oidc/integration_test/userinfo_test.go b/internal/api/oidc/integration_test/userinfo_test.go index d4aded0b48..2a31dd964b 100644 --- a/internal/api/oidc/integration_test/userinfo_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -18,69 +18,11 @@ import ( oidc_api "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/feature/v2" "github.com/zitadel/zitadel/pkg/grpc/management" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) -// TestServer_UserInfo is a top-level test which re-executes the actual -// userinfo integration test against a matrix of different feature flags. -// This ensure that the response of the different implementations remains the same. func TestServer_UserInfo(t *testing.T) { - iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - t.Cleanup(func() { - _, err := Instance.Client.FeatureV2.ResetInstanceFeatures(iamOwnerCTX, &feature.ResetInstanceFeaturesRequest{}) - require.NoError(t, err) - }) - tests := []struct { - name string - legacy bool - trigger bool - webKey bool - }{ - { - name: "legacy enabled", - legacy: true, - }, - { - name: "legacy disabled, trigger disabled", - legacy: false, - trigger: false, - }, - { - name: "legacy disabled, trigger enabled", - legacy: false, - trigger: true, - }, - - // This is the only functional test we need to cover web keys. - // - By creating tokens the signer is tested - // - When obtaining the tokens, the RP verifies the ID Token using the key set from the jwks endpoint. - // - By calling userinfo with the access token as JWT, the Token Verifier with the public key cache is tested. - { - name: "web keys", - legacy: false, - trigger: false, - webKey: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ - OidcLegacyIntrospection: &tt.legacy, - OidcTriggerIntrospectionProjections: &tt.trigger, - WebKey: &tt.webKey, - }) - require.NoError(t, err) - testServer_UserInfo(t) - }) - } -} - -// testServer_UserInfo is the actual userinfo integration test, -// which calls the userinfo endpoint with different client configurations, roles and token scopes. -func testServer_UserInfo(t *testing.T) { const ( roleFoo = "foo" roleBar = "bar" diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index c028013d6a..e5479a4683 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -23,15 +23,6 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR err = oidcError(err) span.EndWithError(err) }() - - features := authz.GetFeatures(ctx) - if features.LegacyIntrospection { - return s.LegacyServer.Introspect(ctx, r) - } - if features.TriggerIntrospectionProjections { - query.TriggerIntrospectionProjections(ctx) - } - ctx, cancel := context.WithCancel(ctx) defer cancel() diff --git a/internal/api/oidc/jwt-profile.go b/internal/api/oidc/jwt-profile.go index fe668b5a8a..d230f58b5b 100644 --- a/internal/api/oidc/jwt-profile.go +++ b/internal/api/oidc/jwt-profile.go @@ -3,38 +3,9 @@ package oidc import ( "context" - "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" - - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" ) -func (o *OPStorage) JWTProfileTokenType(ctx context.Context, request op.TokenRequest) (_ op.AccessTokenType, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - mapJWTProfileScopesToAudience(ctx, request) - user, err := o.query.GetUserByID(ctx, false, request.GetSubject()) - if err != nil { - return 0, err - } - // the user should always be a machine, but let's just be sure - if user.Machine == nil { - return 0, zerrors.ThrowInvalidArgument(nil, "OIDC-jk26S", "invalid client type") - } - return accessTokenTypeToOIDC(user.Machine.AccessTokenType), nil -} - -func mapJWTProfileScopesToAudience(ctx context.Context, request op.TokenRequest) { - // the request should always be a JWTTokenRequest, but let's make sure - jwt, ok := request.(*oidc.JWTTokenRequest) - if !ok { - return - } - jwt.Audience = domain.AddAudScopeToAudience(ctx, jwt.Audience, jwt.Scopes) +func (o *OPStorage) JWTProfileTokenType(context.Context, op.TokenRequest) (op.AccessTokenType, error) { + panic(o.panicErr("JWTProfileTokenType")) } diff --git a/internal/api/oidc/key.go b/internal/api/oidc/key.go index 852bbc7db8..61f874664f 100644 --- a/internal/api/oidc/key.go +++ b/internal/api/oidc/key.go @@ -10,18 +10,12 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/jonboulle/clockwork" - "github.com/muhlemmer/gu" - "github.com/shopspring/decimal" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/zitadel/internal/api/authz" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/internal/repository/instance" - "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -36,11 +30,8 @@ var supportedWebKeyAlgs = []string{ string(jose.ES512), } -func supportedSigningAlgs(ctx context.Context) []string { - if authz.GetFeatures(ctx).WebKey { - return supportedWebKeyAlgs - } - return []string{string(jose.RS256)} +func supportedSigningAlgs() []string { + return supportedWebKeyAlgs } type cachedPublicKey struct { @@ -211,15 +202,6 @@ func withKeyExpiryCheck(check bool) keySetOption { } } -func jsonWebkey(key query.PublicKey) *jose.JSONWebKey { - return &jose.JSONWebKey{ - KeyID: key.ID(), - Algorithm: key.Algorithm(), - Use: key.Use().String(), - Key: key.Key(), - } -} - // keySetMap is a mapping of key IDs to public key data. type keySetMap map[string][]byte @@ -250,7 +232,6 @@ func (k keySetMap) VerifySignature(ctx context.Context, jws *jose.JSONWebSignatu } const ( - locksTable = "projections.locks" signingKey = "signing_key" oidcUser = "OIDC" @@ -279,203 +260,36 @@ func (s *SigningKey) ID() string { return s.id } -// PublicKey wraps the query.PublicKey to implement the op.Key interface -type PublicKey struct { - key query.PublicKey -} - -func (s *PublicKey) Algorithm() jose.SignatureAlgorithm { - return jose.SignatureAlgorithm(s.key.Algorithm()) -} - -func (s *PublicKey) Use() string { - return s.key.Use().String() -} - -func (s *PublicKey) Key() interface{} { - return s.key.Key() -} - -func (s *PublicKey) ID() string { - return s.key.ID() -} - // KeySet implements the op.Storage interface func (o *OPStorage) KeySet(ctx context.Context) (keys []op.Key, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - err = retry(func() error { - publicKeys, err := o.query.ActivePublicKeys(ctx, time.Now()) - if err != nil { - return err - } - keys = make([]op.Key, len(publicKeys.Keys)) - for i, key := range publicKeys.Keys { - keys[i] = &PublicKey{key} - } - return nil - }) - return keys, err + panic(o.panicErr("KeySet")) } // SignatureAlgorithms implements the op.Storage interface func (o *OPStorage) SignatureAlgorithms(ctx context.Context) ([]jose.SignatureAlgorithm, error) { - key, err := o.SigningKey(ctx) - if err != nil { - logging.WithError(err).Warn("unable to fetch signing key") - return nil, err - } - return []jose.SignatureAlgorithm{key.SignatureAlgorithm()}, nil + panic(o.panicErr("SignatureAlgorithms")) } // SigningKey implements the op.Storage interface func (o *OPStorage) SigningKey(ctx context.Context) (key op.SigningKey, err error) { - err = retry(func() error { - key, err = o.getSigningKey(ctx) - if err != nil { - return err - } - if key == nil { - return zerrors.ThrowNotFound(nil, "OIDC-ve4Qu", "Errors.Internal") - } - return nil - }) - return key, err -} - -func (o *OPStorage) getSigningKey(ctx context.Context) (op.SigningKey, error) { - keys, err := o.query.ActivePrivateSigningKey(ctx, time.Now().Add(gracefulPeriod)) - if err != nil { - return nil, err - } - if len(keys.Keys) > 0 { - return PrivateKeyToSigningKey(SelectSigningKey(keys.Keys), o.encAlg) - } - var position decimal.Decimal - if keys.State != nil { - position = keys.State.Position - } - return nil, o.refreshSigningKey(ctx, position) -} - -func (o *OPStorage) refreshSigningKey(ctx context.Context, position decimal.Decimal) error { - ok, err := o.ensureIsLatestKey(ctx, position) - if err != nil || !ok { - return zerrors.ThrowInternal(err, "OIDC-ASfh3", "cannot ensure that projection is up to date") - } - err = o.lockAndGenerateSigningKeyPair(ctx) - if err != nil { - return zerrors.ThrowInternal(err, "OIDC-ADh31", "could not create signing key") - } - return zerrors.ThrowInternal(nil, "OIDC-Df1bh", "") -} - -func (o *OPStorage) ensureIsLatestKey(ctx context.Context, position decimal.Decimal) (bool, error) { - maxSequence, err := o.getMaxKeyPosition(ctx) - if err != nil { - return false, fmt.Errorf("error retrieving new events: %w", err) - } - return position.GreaterThanOrEqual(maxSequence), nil -} - -func PrivateKeyToSigningKey(key query.PrivateKey, algorithm crypto.EncryptionAlgorithm) (_ op.SigningKey, err error) { - keyData, err := crypto.Decrypt(key.Key(), algorithm) - if err != nil { - return nil, err - } - privateKey, err := crypto.BytesToPrivateKey(keyData) - if err != nil { - return nil, err - } - return &SigningKey{ - algorithm: jose.SignatureAlgorithm(key.Algorithm()), - key: privateKey, - id: key.ID(), - }, nil -} - -func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context) error { - logging.Info("lock and generate signing key pair") - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - errs := o.locker.Lock(ctx, lockDuration, authz.GetInstance(ctx).InstanceID()) - err, ok := <-errs - if err != nil || !ok { - if zerrors.IsErrorAlreadyExists(err) { - return nil - } - logging.OnError(err).Debug("initial lock failed") - return err - } - - return o.command.GenerateSigningKeyPair(setOIDCCtx(ctx), "RS256") -} - -func (o *OPStorage) getMaxKeyPosition(ctx context.Context) (decimal.Decimal, error) { - return o.eventstore.LatestPosition(ctx, - eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). - ResourceOwner(authz.GetInstance(ctx).InstanceID()). - AwaitOpenTransactions(). - AddQuery(). - AggregateTypes( - keypair.AggregateType, - instance.AggregateType, - ). - EventTypes( - keypair.AddedEventType, - instance.InstanceRemovedEventType, - ). - Builder(), - ) -} - -func SelectSigningKey(keys []query.PrivateKey) query.PrivateKey { - return keys[len(keys)-1] -} - -func setOIDCCtx(ctx context.Context) context.Context { - return authz.SetCtxData(ctx, authz.CtxData{UserID: oidcUser, OrgID: authz.GetInstance(ctx).InstanceID()}) -} - -func retry(retryable func() error) (err error) { - for i := 0; i < retryCount; i++ { - err = retryable() - if err == nil { - return nil - } - time.Sleep(retryBackoff) - } - return err + panic(o.panicErr("SigningKey")) } func (s *Server) Keys(ctx context.Context, r *op.Request[struct{}]) (_ *op.Response, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if !authz.GetFeatures(ctx).WebKey { - return s.LegacyServer.Keys(ctx, r) - } - keyset, err := s.query.GetWebKeySet(ctx) if err != nil { return nil, err } - // Return legacy keys, so we do not invalidate all tokens - // once the feature flag is enabled. - legacyKeys, err := s.query.ActivePublicKeys(ctx, time.Now()) - logging.OnError(err).Error("oidc server: active public keys (legacy)") - appendPublicKeysToWebKeySet(keyset, legacyKeys) - resp := op.NewResponse(keyset) if s.jwksCacheControlMaxAge != 0 { resp.Header.Set(http_util.CacheControl, fmt.Sprintf("max-age=%d, must-revalidate", int(s.jwksCacheControlMaxAge/time.Second)), ) } - return resp, nil } @@ -497,20 +311,10 @@ func appendPublicKeysToWebKeySet(keyset *jose.JSONWebKeySet, pubkeys *query.Publ func queryKeyFunc(q *query.Queries) func(ctx context.Context, keyID string) (*jose.JSONWebKey, *time.Time, error) { return func(ctx context.Context, keyID string) (*jose.JSONWebKey, *time.Time, error) { - if authz.GetFeatures(ctx).WebKey { - webKey, err := q.GetPublicWebKeyByID(ctx, keyID) - if err == nil { - return webKey, nil, nil - } - if !zerrors.IsNotFound(err) { - return nil, nil, err - } - } - - pubKey, err := q.GetPublicKeyByID(ctx, keyID) + webKey, err := q.GetPublicWebKeyByID(ctx, keyID) if err != nil { return nil, nil, err } - return jsonWebkey(pubKey), gu.Ptr(pubKey.Expiry()), nil + return webKey, nil, nil } } diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go index d7171b957b..6f59ce3525 100644 --- a/internal/api/oidc/op.go +++ b/internal/api/oidc/op.go @@ -18,10 +18,8 @@ import ( "github.com/zitadel/zitadel/internal/cache" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain/federatedlogout" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/handler/crdb" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/metrics" "github.com/zitadel/zitadel/internal/zerrors" @@ -75,7 +73,6 @@ type OPStorage struct { defaultRefreshTokenIdleExpiration time.Duration defaultRefreshTokenExpiration time.Duration encAlg crypto.EncryptionAlgorithm - locker crdb.Locker assetAPIPrefix func(ctx context.Context) string contextToIssuer func(context.Context) string federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout] @@ -91,14 +88,14 @@ type Provider struct { // IDTokenHintVerifier configures a Verifier and supported signing algorithms based on the Web Key feature in the context. func (o *Provider) IDTokenHintVerifier(ctx context.Context) *op.IDTokenHintVerifier { return op.NewIDTokenHintVerifier(op.IssuerFromContext(ctx), o.idTokenHintKeySet, op.WithSupportedIDTokenHintSigningAlgorithms( - supportedSigningAlgs(ctx)..., + supportedSigningAlgs()..., )) } // AccessTokenVerifier configures a Verifier and supported signing algorithms based on the Web Key feature in the context. func (o *Provider) AccessTokenVerifier(ctx context.Context) *op.AccessTokenVerifier { return op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), o.accessTokenKeySet, op.WithSupportedAccessTokenSigningAlgorithms( - supportedSigningAlgs(ctx)..., + supportedSigningAlgs()..., )) } @@ -113,7 +110,6 @@ func NewServer( encryptionAlg crypto.EncryptionAlgorithm, cryptoKey []byte, es *eventstore.Eventstore, - projections *database.DB, userAgentCookie, instanceHandler func(http.Handler) http.Handler, accessHandler *middleware.AccessInterceptor, fallbackLogger *slog.Logger, @@ -124,7 +120,7 @@ func NewServer( if err != nil { return nil, zerrors.ThrowInternal(err, "OIDC-EGrqd", "cannot create op config: %w") } - storage := newStorage(config, command, query, repo, encryptionAlg, es, projections, ContextToIssuer, federatedLogoutCache) + storage := newStorage(config, command, query, repo, encryptionAlg, es, ContextToIssuer, federatedLogoutCache) keyCache := newPublicKeyCache(ctx, config.PublicKeyCacheMaxAge, queryKeyFunc(query)) accessTokenKeySet := newOidcKeySet(keyCache, withKeyExpiryCheck(true)) idTokenHintKeySet := newOidcKeySet(keyCache) @@ -236,7 +232,6 @@ func newStorage( repo repository.Repository, encAlg crypto.EncryptionAlgorithm, es *eventstore.Eventstore, - db *database.DB, contextToIssuer func(context.Context) string, federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout], ) *OPStorage { @@ -253,7 +248,6 @@ func newStorage( defaultRefreshTokenIdleExpiration: config.DefaultRefreshTokenIdleExpiration, defaultRefreshTokenExpiration: config.DefaultRefreshTokenExpiration, encAlg: encAlg, - locker: crdb.NewLocker(db.DB, locksTable, signingKey), assetAPIPrefix: assets.AssetAPI(), contextToIssuer: contextToIssuer, federateLogoutCache: federateLogoutCache, diff --git a/internal/api/oidc/server.go b/internal/api/oidc/server.go index 1a0854e2a6..df7127443f 100644 --- a/internal/api/oidc/server.go +++ b/internal/api/oidc/server.go @@ -188,7 +188,7 @@ func (s *Server) createDiscoveryConfig(ctx context.Context, supportedUILocales o }, GrantTypesSupported: op.GrantTypes(s.Provider()), SubjectTypesSupported: op.SubjectTypes(s.Provider()), - IDTokenSigningAlgValuesSupported: supportedSigningAlgs(ctx), + IDTokenSigningAlgValuesSupported: supportedSigningAlgs(), RequestObjectSigningAlgValuesSupported: op.RequestObjectSigAlgorithms(s.Provider()), TokenEndpointAuthMethodsSupported: op.AuthMethodsTokenEndpoint(s.Provider()), TokenEndpointAuthSigningAlgValuesSupported: op.TokenSigAlgorithms(s.Provider()), diff --git a/internal/api/oidc/server_test.go b/internal/api/oidc/server_test.go index 76d073151a..9bf22fd210 100644 --- a/internal/api/oidc/server_test.go +++ b/internal/api/oidc/server_test.go @@ -8,9 +8,6 @@ import ( "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" "golang.org/x/text/language" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/feature" ) func TestServer_createDiscoveryConfig(t *testing.T) { @@ -63,92 +60,6 @@ func TestServer_createDiscoveryConfig(t *testing.T) { ctx: op.ContextWithIssuer(context.Background(), "https://issuer.com"), supportedUILocales: []language.Tag{language.English, language.German}, }, - &oidc.DiscoveryConfiguration{ - Issuer: "https://issuer.com", - AuthorizationEndpoint: "https://issuer.com/auth", - TokenEndpoint: "https://issuer.com/token", - IntrospectionEndpoint: "https://issuer.com/introspect", - UserinfoEndpoint: "https://issuer.com/userinfo", - RevocationEndpoint: "https://issuer.com/revoke", - EndSessionEndpoint: "https://issuer.com/logout", - DeviceAuthorizationEndpoint: "https://issuer.com/device", - CheckSessionIframe: "", - JwksURI: "https://issuer.com/keys", - RegistrationEndpoint: "", - ScopesSupported: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopePhone, oidc.ScopeAddress, oidc.ScopeOfflineAccess}, - ResponseTypesSupported: []string{string(oidc.ResponseTypeCode), string(oidc.ResponseTypeIDTokenOnly), string(oidc.ResponseTypeIDToken)}, - ResponseModesSupported: []string{string(oidc.ResponseModeQuery), string(oidc.ResponseModeFragment), string(oidc.ResponseModeFormPost)}, - GrantTypesSupported: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeImplicit, oidc.GrantTypeRefreshToken, oidc.GrantTypeBearer}, - ACRValuesSupported: nil, - SubjectTypesSupported: []string{"public"}, - IDTokenSigningAlgValuesSupported: []string{"RS256"}, - IDTokenEncryptionAlgValuesSupported: nil, - IDTokenEncryptionEncValuesSupported: nil, - UserinfoSigningAlgValuesSupported: nil, - UserinfoEncryptionAlgValuesSupported: nil, - UserinfoEncryptionEncValuesSupported: nil, - RequestObjectSigningAlgValuesSupported: []string{"RS256"}, - RequestObjectEncryptionAlgValuesSupported: nil, - RequestObjectEncryptionEncValuesSupported: nil, - TokenEndpointAuthMethodsSupported: []oidc.AuthMethod{oidc.AuthMethodNone, oidc.AuthMethodBasic, oidc.AuthMethodPost, oidc.AuthMethodPrivateKeyJWT}, - TokenEndpointAuthSigningAlgValuesSupported: []string{"RS256"}, - RevocationEndpointAuthMethodsSupported: []oidc.AuthMethod{oidc.AuthMethodNone, oidc.AuthMethodBasic, oidc.AuthMethodPost, oidc.AuthMethodPrivateKeyJWT}, - RevocationEndpointAuthSigningAlgValuesSupported: []string{"RS256"}, - IntrospectionEndpointAuthMethodsSupported: []oidc.AuthMethod{oidc.AuthMethodBasic, oidc.AuthMethodPrivateKeyJWT}, - IntrospectionEndpointAuthSigningAlgValuesSupported: []string{"RS256"}, - DisplayValuesSupported: nil, - ClaimTypesSupported: nil, - ClaimsSupported: []string{"sub", "aud", "exp", "iat", "iss", "auth_time", "nonce", "acr", "amr", "c_hash", "at_hash", "act", "scopes", "client_id", "azp", "preferred_username", "name", "family_name", "given_name", "locale", "email", "email_verified", "phone_number", "phone_number_verified"}, - ClaimsParameterSupported: false, - CodeChallengeMethodsSupported: []oidc.CodeChallengeMethod{"S256"}, - ServiceDocumentation: "", - ClaimsLocalesSupported: nil, - UILocalesSupported: []language.Tag{language.English, language.German}, - RequestParameterSupported: true, - RequestURIParameterSupported: false, - RequireRequestURIRegistration: false, - OPPolicyURI: "", - OPTermsOfServiceURI: "", - }, - }, - { - "web keys feature enabled", - fields{ - LegacyServer: op.NewLegacyServer( - func() *op.Provider { - //nolint:staticcheck - provider, _ := op.NewForwardedOpenIDProvider("path", - &op.Config{ - CodeMethodS256: true, - AuthMethodPost: true, - AuthMethodPrivateKeyJWT: true, - GrantTypeRefreshToken: true, - RequestObjectSupported: true, - }, - nil, - ) - return provider - }(), - op.Endpoints{ - Authorization: op.NewEndpoint("auth"), - Token: op.NewEndpoint("token"), - Introspection: op.NewEndpoint("introspect"), - Userinfo: op.NewEndpoint("userinfo"), - Revocation: op.NewEndpoint("revoke"), - EndSession: op.NewEndpoint("logout"), - JwksURI: op.NewEndpoint("keys"), - DeviceAuthorization: op.NewEndpoint("device"), - }, - ), - signingKeyAlgorithm: "RS256", - }, - args{ - ctx: authz.WithFeatures( - op.ContextWithIssuer(context.Background(), "https://issuer.com"), - feature.Features{WebKey: true}, - ), - supportedUILocales: []language.Tag{language.English, language.German}, - }, &oidc.DiscoveryConfiguration{ Issuer: "https://issuer.com", AuthorizationEndpoint: "https://issuer.com/auth", diff --git a/internal/api/oidc/token.go b/internal/api/oidc/token.go index 485f455784..2efc0fb583 100644 --- a/internal/api/oidc/token.go +++ b/internal/api/oidc/token.go @@ -12,7 +12,6 @@ import ( "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -64,14 +63,13 @@ func (s *Server) accessTokenResponseFromSession(ctx context.Context, client op.C type SignerFunc func(ctx context.Context) (jose.Signer, jose.SignatureAlgorithm, error) func (s *Server) getSignerOnce() SignerFunc { - return GetSignerOnce(s.query.GetActiveSigningWebKey, s.Provider().Storage().SigningKey) + return GetSignerOnce(s.query.GetActiveSigningWebKey) } // GetSignerOnce returns a function which retrieves the instance's signer from the database once. // Repeated calls of the returned function return the same results. func GetSignerOnce( getActiveSigningWebKey func(ctx context.Context) (*jose.JSONWebKey, error), - getSigningKey func(ctx context.Context) (op.SigningKey, error), ) SignerFunc { var ( once sync.Once @@ -84,23 +82,12 @@ func GetSignerOnce( ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if authz.GetFeatures(ctx).WebKey { - var webKey *jose.JSONWebKey - webKey, err = getActiveSigningWebKey(ctx) - if err != nil { - return - } - signer, signAlg, err = signerFromWebKey(webKey) - return - } - - var signingKey op.SigningKey - signingKey, err = getSigningKey(ctx) + var webKey *jose.JSONWebKey + webKey, err = getActiveSigningWebKey(ctx) if err != nil { return } - signAlg = signingKey.SignatureAlgorithm() - signer, err = op.SignerFromKey(signingKey) + signer, signAlg, err = signerFromWebKey(webKey) }) return signer, signAlg, err } diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index 61f03b6d0f..170ff49c94 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -33,15 +33,6 @@ func (s *Server) UserInfo(ctx context.Context, r *op.Request[oidc.UserInfoReques err = oidcError(err) span.EndWithError(err) }() - - features := authz.GetFeatures(ctx) - if features.LegacyIntrospection { - return s.LegacyServer.UserInfo(ctx, r) - } - if features.TriggerIntrospectionProjections { - query.TriggerOIDCUserInfoProjections(ctx) - } - token, err := s.verifyAccessToken(ctx, r.Data.AccessToken) if err != nil { return nil, op.NewStatusError(oidc.ErrAccessDenied().WithDescription("access token invalid").WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError), http.StatusUnauthorized) diff --git a/internal/api/saml/certificate.go b/internal/api/saml/certificate.go index 14752cd5cd..e0eb31255e 100644 --- a/internal/api/saml/certificate.go +++ b/internal/api/saml/certificate.go @@ -14,13 +14,14 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/zerrors" ) const ( - locksTable = "projections.locks" + locksTable = projection.LocksTable signingKey = "signing_key" samlUser = "SAML" diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index 935e986c72..5a7f6cb576 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -309,7 +309,7 @@ func (p *Storage) getCustomAttributes(ctx context.Context, user *query.User, use true, user.ID, &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{resourceOwnerQuery}}, - false, + nil, ) if err != nil { logging.WithError(err).Info("unable to get md in action") @@ -490,7 +490,7 @@ func (p *Storage) getGrants(ctx context.Context, userID, applicationID string) ( userIDQuery, activeQuery, }, - }, true) + }, true, nil) } type customAttribute struct { diff --git a/internal/api/scim/integration_test/bulk_test.go b/internal/api/scim/integration_test/bulk_test.go index 660b10f4fd..c2f430c1b7 100644 --- a/internal/api/scim/integration_test/bulk_test.go +++ b/internal/api/scim/integration_test/bulk_test.go @@ -17,6 +17,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/zitadel/zitadel/internal/api/scim/resources" "github.com/zitadel/zitadel/internal/api/scim/schemas" @@ -478,7 +480,7 @@ func TestBulk(t *testing.T) { }, { name: "fail on errors", - body: bulkFailOnErrorsJson, + body: withUsername(bulkFailOnErrorsJson, gofakeit.Username()), want: &scim.BulkResponse{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse}, Operations: []*scim.BulkResponseOperation{ @@ -579,7 +581,6 @@ func TestBulk(t *testing.T) { response, err := Instance.Client.SCIM.Bulk(ctx, orgID, tt.body) createdUserIDs := buildCreatedIDs(response) - defer deleteUsers(t, createdUserIDs) if tt.wantErr != nil { statusCode := tt.wantErr.status @@ -656,17 +657,6 @@ func buildCreatedIDs(response *scim.BulkResponse) []string { return createdIds } -func deleteUsers(t require.TestingT, ids []string) { - for _, id := range ids { - err := Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, id) - - // only not found errors are ok (if the user is deleted in a later on bulk request) - if err != nil { - scim.RequireScimError(t, http.StatusNotFound, err) - } - } -} - func buildMinimalUpdateRequest(userID string) *scim.BulkRequest { return &scim.BulkRequest{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkRequest}, @@ -690,7 +680,7 @@ func buildTooManyOperationsRequest() *scim.BulkRequest { req.Operations[i] = &scim.BulkRequestOperation{ Method: http.MethodPost, Path: "/Users", - Data: minimalUserJson, + Data: withUsername(minimalUserJson, gofakeit.Username()), } } @@ -720,8 +710,11 @@ func ensureMetadataProjected(t require.TestingT, userID, key, value string) { Id: userID, Key: key, }) - require.NoError(tt, err) - require.Equal(tt, value, string(md.Metadata.Value)) + if !assert.NoError(tt, err) { + require.Equal(tt, status.Code(err), codes.NotFound) + return + } + assert.Equal(tt, value, string(md.Metadata.Value)) }, retryDuration, tick) } diff --git a/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json b/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json index bc9ed1346a..d1d0c88fe6 100644 --- a/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json +++ b/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json @@ -32,14 +32,14 @@ "urn:ietf:params:scim:schemas:core:2.0:User" ], "externalId": "scim-bulk-created-user-0", - "userName": "scim-bulk-created-user-0", + "userName": "{{ .Username }}", "name": { "familyName": "scim-bulk-created-user-0-family-name", "givenName": "scim-bulk-created-user-0-given-name" }, "emails": [ { - "value": "scim-bulk-created-user-0@example.com", + "value": "{{ .Username }}@example.com", "primary": true } ], diff --git a/internal/api/scim/integration_test/testdata/users_create_test_full.json b/internal/api/scim/integration_test/testdata/users_create_test_full.json index 7879ecf160..cfc786a7e3 100644 --- a/internal/api/scim/integration_test/testdata/users_create_test_full.json +++ b/internal/api/scim/integration_test/testdata/users_create_test_full.json @@ -1,7 +1,7 @@ { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "externalId": "701984", - "userName": "bjensen@example.com", + "userName": "{{ .Username }}@example.com", "name": { "formatted": "Ms. Barbara J Jensen, III", "familyName": "Jensen", @@ -15,12 +15,12 @@ "profileUrl": "http://login.example.com/bjensen", "emails": [ { - "value": "bjensen@example.com", + "value": "{{ .Username }}@example.com", "type": "work", "primary": true }, { - "value": "babs@jensen.org", + "value": "{{ .Username }}+1@example.com", "type": "home" } ], diff --git a/internal/api/scim/integration_test/testdata/users_create_test_minimal.json b/internal/api/scim/integration_test/testdata/users_create_test_minimal.json index c51f416bc7..bdceb3e45f 100644 --- a/internal/api/scim/integration_test/testdata/users_create_test_minimal.json +++ b/internal/api/scim/integration_test/testdata/users_create_test_minimal.json @@ -2,14 +2,14 @@ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], - "userName": "acmeUser1", + "userName": "{{ .Username }}", "name": { "familyName": "Ross", "givenName": "Bethany" }, "emails": [ { - "value": "user1@example.com", + "value": "{{ .Username }}@example.com", "primary": true } ] diff --git a/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json b/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json index 11650674a6..95ca1246d5 100644 --- a/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json +++ b/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json @@ -2,14 +2,14 @@ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], - "userName": "acmeUser1", + "userName": "acmeUser1-inactive", "name": { "familyName": "Ross", "givenName": "Bethany" }, "emails": [ { - "value": "user1@example.com", + "value": "user1-inactive@example.com", "primary": true } ], diff --git a/internal/api/scim/integration_test/testdata/users_create_test_no_primary_email_phone.json b/internal/api/scim/integration_test/testdata/users_create_test_no_primary_email_phone.json index 20c67c4715..f2b4bf2e4c 100644 --- a/internal/api/scim/integration_test/testdata/users_create_test_no_primary_email_phone.json +++ b/internal/api/scim/integration_test/testdata/users_create_test_no_primary_email_phone.json @@ -2,14 +2,14 @@ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], - "userName": "acmeUser1", + "userName": "acmeUser1-no-primary-email-phone", "name": { "familyName": "Ross", "givenName": "Bethany" }, "emails": [ { - "value": "user1@example.com" + "value": "user1-no-primary-email-phone@example.com" } ], "phoneNumbers": [ diff --git a/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json index b7e8d87590..33d78a2e3a 100644 --- a/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json +++ b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json @@ -2,14 +2,14 @@ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], - "userName": "acmeUser1-minimal-replaced", + "userName": "{{ .Username }}", "name": { "familyName": "Ross-replaced", "givenName": "Bethany-replaced" }, "emails": [ { - "value": "user1-minimal-replaced@example.com", + "value": "{{ .Username }}@example.com", "primary": true, "type": "work" } diff --git a/internal/api/scim/integration_test/testdata/users_update_test_full.json b/internal/api/scim/integration_test/testdata/users_update_test_full.json index 23403f3e5b..f79a0b5332 100644 --- a/internal/api/scim/integration_test/testdata/users_update_test_full.json +++ b/internal/api/scim/integration_test/testdata/users_update_test_full.json @@ -7,7 +7,7 @@ "value": { "emails":[ { - "value":"babs@example.com", + "value":"{{ .Username }}+2@example.com", "type":"home", "primary": true } diff --git a/internal/api/scim/integration_test/users_create_test.go b/internal/api/scim/integration_test/users_create_test.go index 35d5297878..4d1d9268ce 100644 --- a/internal/api/scim/integration_test/users_create_test.go +++ b/internal/api/scim/integration_test/users_create_test.go @@ -3,17 +3,22 @@ package integration_test import ( + "bytes" "context" _ "embed" + "fmt" "net/http" "path" "testing" + "text/template" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/zitadel/zitadel/internal/api/scim/resources" "github.com/zitadel/zitadel/internal/api/scim/schemas" @@ -157,7 +162,18 @@ var ( } ) +func withUsername(fixture []byte, username string) []byte { + buf := new(bytes.Buffer) + template.Must(template.New("").Parse(string(fixture))).Execute(buf, &struct { + Username string + }{ + Username: username, + }) + return buf.Bytes() +} + func TestCreateUser(t *testing.T) { + minimalUsername := gofakeit.Username() tests := []struct { name string body []byte @@ -171,16 +187,16 @@ func TestCreateUser(t *testing.T) { }{ { name: "minimal user", - body: minimalUserJson, + body: withUsername(minimalUserJson, minimalUsername), want: &resources.ScimUser{ - UserName: "acmeUser1", + UserName: minimalUsername, Name: &resources.ScimUserName{ FamilyName: "Ross", GivenName: "Bethany", }, Emails: []*resources.ScimEmail{ { - Value: "user1@example.com", + Value: minimalUsername + "@example.com", Primary: true, }, }, @@ -195,7 +211,7 @@ func TestCreateUser(t *testing.T) { }, { name: "full user", - body: fullUserJson, + body: withUsername(fullUserJson, "bjensen"), want: fullUser, }, { @@ -204,7 +220,7 @@ func TestCreateUser(t *testing.T) { want: &resources.ScimUser{ Emails: []*resources.ScimEmail{ { - Value: "user1@example.com", + Value: "user1-no-primary-email-phone@example.com", Primary: true, }, }, @@ -262,21 +278,21 @@ func TestCreateUser(t *testing.T) { }, { name: "not authenticated", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), ctx: context.Background(), wantErr: true, errorStatus: http.StatusUnauthorized, }, { name: "no permissions", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), wantErr: true, errorStatus: http.StatusNotFound, }, { name: "another org", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), orgID: SecondaryOrganization.OrganizationId, wantErr: true, errorStatus: http.StatusNotFound, @@ -315,11 +331,6 @@ func TestCreateUser(t *testing.T) { } assert.NotEmpty(t, createdUser.ID) - defer func() { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - assert.NoError(t, err) - }() - assert.EqualValues(t, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, createdUser.Resource.Schemas) assert.Equal(t, schemas.ScimResourceTypeSingular("User"), createdUser.Resource.Meta.ResourceType) assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, orgID, "Users", createdUser.ID), createdUser.Resource.Meta.Location) @@ -345,10 +356,11 @@ func TestCreateUser(t *testing.T) { } func TestCreateUser_duplicate(t *testing.T) { - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, minimalUserJson) + parsedMinimalUserJson := withUsername(minimalUserJson, gofakeit.Username()) + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, parsedMinimalUserJson) require.NoError(t, err) - _, err = Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, minimalUserJson) + _, err = Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, parsedMinimalUserJson) scimErr := scim.RequireScimError(t, http.StatusConflict, err) assert.Equal(t, "User already exists", scimErr.Error.Detail) assert.Equal(t, "uniqueness", scimErr.Error.ScimType) @@ -358,14 +370,10 @@ func TestCreateUser_duplicate(t *testing.T) { } func TestCreateUser_metadata(t *testing.T) { - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + username := gofakeit.Username() + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) require.NoError(t, err) - defer func() { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) - }() - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) require.EventuallyWithT(t, func(tt *assert.CollectT) { md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ @@ -391,38 +399,36 @@ func TestCreateUser_metadata(t *testing.T) { test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:locale", "en-US") test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`) test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:roles", `[{"value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true},{"value":"my-role-2","display":"Rolle 2","type":"secondary-role"}]`) - test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", `[{"value":"bjensen@example.com","primary":true,"type":"work"},{"value":"babs@jensen.org","primary":false,"type":"home"}]`) + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", fmt.Sprintf(`[{"value":"%s@example.com","primary":true,"type":"work"},{"value":"%s+1@example.com","primary":false,"type":"home"}]`, username, username)) }, retryDuration, tick) } func TestCreateUser_scopedExternalID(t *testing.T) { - setProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID, "fooBar") - - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + callingUserId, callingUserPat, err := Instance.CreateMachineUserPATWithMembership(CTX, "ORG_OWNER") require.NoError(t, err) - - defer func() { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) - - removeProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID) - }() - - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + ctx := integration.WithAuthorizationToken(CTX, callingUserPat) + setProvisioningDomain(t, callingUserId, "fooBar") + createdUser, err := Instance.Client.SCIM.Users.Create(ctx, Instance.DefaultOrg.Id, withUsername(fullUserJson, gofakeit.Username())) + require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) require.EventuallyWithT(t, func(tt *assert.CollectT) { // unscoped externalID should not exist - _, err = Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ + unscoped, err := Instance.Client.Mgmt.GetUserMetadata(ctx, &management.GetUserMetadataRequest{ Id: createdUser.ID, Key: "urn:zitadel:scim:externalId", }) integration.AssertGrpcStatus(tt, codes.NotFound, err) + unscoped = unscoped // scoped externalID should exist - md, err := Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ + md, err := Instance.Client.Mgmt.GetUserMetadata(ctx, &management.GetUserMetadataRequest{ Id: createdUser.ID, Key: "urn:zitadel:scim:fooBar:externalId", }) - require.NoError(tt, err) + if !assert.NoError(tt, err) { + require.Equal(tt, status.Code(err), codes.NotFound) + return + } assert.Equal(tt, "701984", string(md.Metadata.Value)) }, retryDuration, tick) } diff --git a/internal/api/scim/integration_test/users_get_test.go b/internal/api/scim/integration_test/users_get_test.go index 8a1bab6c93..08e09946fb 100644 --- a/internal/api/scim/integration_test/users_get_test.go +++ b/internal/api/scim/integration_test/users_get_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" @@ -18,256 +19,260 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/internal/test" - "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func TestGetUser(t *testing.T) { - tests := []struct { - name string - orgID string - buildUserID func() string - cleanup func(userID string) + type testCase struct { ctx context.Context + orgID string + userID string want *resources.ScimUser wantErr bool errorStatus int + } + tests := []struct { + name string + setup func(t *testing.T) testCase }{ { - name: "not authenticated", - ctx: context.Background(), - errorStatus: http.StatusUnauthorized, - wantErr: true, + name: "not authenticated", + setup: func(t *testing.T) testCase { + return testCase{ + ctx: context.Background(), + errorStatus: http.StatusUnauthorized, + wantErr: true, + } + }, }, { - name: "no permissions", - ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), - errorStatus: http.StatusNotFound, - wantErr: true, + name: "no permissions", + setup: func(t *testing.T) testCase { + return testCase{ + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + errorStatus: http.StatusNotFound, + wantErr: true, + } + }, }, { - name: "another org", - orgID: SecondaryOrganization.OrganizationId, - errorStatus: http.StatusNotFound, - wantErr: true, + name: "another org", + setup: func(t *testing.T) testCase { + return testCase{ + orgID: SecondaryOrganization.OrganizationId, + errorStatus: http.StatusNotFound, + wantErr: true, + } + }, }, { - name: "another org with permissions", - orgID: SecondaryOrganization.OrganizationId, - ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), - errorStatus: http.StatusNotFound, - wantErr: true, + name: "another org with permissions", + setup: func(t *testing.T) testCase { + return testCase{ + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + orgID: SecondaryOrganization.OrganizationId, + errorStatus: http.StatusNotFound, + wantErr: true, + } + }, }, { name: "unknown user id", - buildUserID: func() string { - return "unknown" + setup: func(t *testing.T) testCase { + return testCase{ + userID: "unknown", + errorStatus: http.StatusNotFound, + wantErr: true, + } }, - errorStatus: http.StatusNotFound, - wantErr: true, }, { name: "created via grpc", - want: &resources.ScimUser{ - Name: &resources.ScimUserName{ - FamilyName: "Mouse", - GivenName: "Mickey", - }, - PreferredLanguage: language.MustParse("nl"), - PhoneNumbers: []*resources.ScimPhoneNumber{ - { - Value: "+41791234567", - Primary: true, + setup: func(t *testing.T) testCase { + return testCase{ + want: &resources.ScimUser{ + Name: &resources.ScimUserName{ + FamilyName: "Mouse", + GivenName: "Mickey", + }, + PreferredLanguage: language.MustParse("nl"), + PhoneNumbers: []*resources.ScimPhoneNumber{ + { + Value: "+41791234567", + Primary: true, + }, + }, }, - }, + } }, }, { name: "created via scim", - buildUserID: func() string { - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) require.NoError(t, err) - return createdUser.ID - }, - cleanup: func(userID string) { - _, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID}) - require.NoError(t, err) - }, - want: &resources.ScimUser{ - ExternalID: "701984", - UserName: "bjensen@example.com", - Name: &resources.ScimUserName{ - Formatted: "Babs Jensen", // DisplayName takes precedence - FamilyName: "Jensen", - GivenName: "Barbara", - MiddleName: "Jane", - HonorificPrefix: "Ms.", - HonorificSuffix: "III", - }, - DisplayName: "Babs Jensen", - NickName: "Babs", - ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), - Title: "Tour Guide", - PreferredLanguage: language.Make("en-US"), - Locale: "en-US", - Timezone: "America/Los_Angeles", - Active: schemas.NewRelaxedBool(true), - Emails: []*resources.ScimEmail{ - { - Value: "bjensen@example.com", - Primary: true, - Type: "work", + return testCase{ + userID: createdUser.ID, + want: &resources.ScimUser{ + ExternalID: "701984", + UserName: username + "@example.com", + Name: &resources.ScimUserName{ + Formatted: "Babs Jensen", // DisplayName takes precedence + FamilyName: "Jensen", + GivenName: "Barbara", + MiddleName: "Jane", + HonorificPrefix: "Ms.", + HonorificSuffix: "III", + }, + DisplayName: "Babs Jensen", + NickName: "Babs", + ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), + Title: "Tour Guide", + PreferredLanguage: language.Make("en-US"), + Locale: "en-US", + Timezone: "America/Los_Angeles", + Active: schemas.NewRelaxedBool(true), + Emails: []*resources.ScimEmail{ + { + Value: username + "@example.com", + Primary: true, + Type: "work", + }, + }, + PhoneNumbers: []*resources.ScimPhoneNumber{ + { + Value: "+415555555555", + Primary: true, + }, + }, + Ims: []*resources.ScimIms{ + { + Value: "someaimhandle", + Type: "aim", + }, + { + Value: "twitterhandle", + Type: "X", + }, + }, + Addresses: []*resources.ScimAddress{ + { + Type: "work", + StreetAddress: "100 Universal City Plaza", + Locality: "Hollywood", + Region: "CA", + PostalCode: "91608", + Country: "USA", + Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA", + Primary: true, + }, + { + Type: "home", + StreetAddress: "456 Hollywood Blvd", + Locality: "Hollywood", + Region: "CA", + PostalCode: "91608", + Country: "USA", + Formatted: "456 Hollywood Blvd\nHollywood, CA 91608 USA", + }, + }, + Photos: []*resources.ScimPhoto{ + { + Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), + Type: "photo", + }, + { + Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")), + Type: "thumbnail", + }, + }, + Roles: []*resources.ScimRole{ + { + Value: "my-role-1", + Display: "Rolle 1", + Type: "main-role", + Primary: true, + }, + { + Value: "my-role-2", + Display: "Rolle 2", + Type: "secondary-role", + Primary: false, + }, + }, + Entitlements: []*resources.ScimEntitlement{ + { + Value: "my-entitlement-1", + Display: "Entitlement 1", + Type: "main-entitlement", + Primary: true, + }, + { + Value: "my-entitlement-2", + Display: "Entitlement 2", + Type: "secondary-entitlement", + Primary: false, + }, + }, }, - }, - PhoneNumbers: []*resources.ScimPhoneNumber{ - { - Value: "+415555555555", - Primary: true, - }, - }, - Ims: []*resources.ScimIms{ - { - Value: "someaimhandle", - Type: "aim", - }, - { - Value: "twitterhandle", - Type: "X", - }, - }, - Addresses: []*resources.ScimAddress{ - { - Type: "work", - StreetAddress: "100 Universal City Plaza", - Locality: "Hollywood", - Region: "CA", - PostalCode: "91608", - Country: "USA", - Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA", - Primary: true, - }, - { - Type: "home", - StreetAddress: "456 Hollywood Blvd", - Locality: "Hollywood", - Region: "CA", - PostalCode: "91608", - Country: "USA", - Formatted: "456 Hollywood Blvd\nHollywood, CA 91608 USA", - }, - }, - Photos: []*resources.ScimPhoto{ - { - Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), - Type: "photo", - }, - { - Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")), - Type: "thumbnail", - }, - }, - Roles: []*resources.ScimRole{ - { - Value: "my-role-1", - Display: "Rolle 1", - Type: "main-role", - Primary: true, - }, - { - Value: "my-role-2", - Display: "Rolle 2", - Type: "secondary-role", - Primary: false, - }, - }, - Entitlements: []*resources.ScimEntitlement{ - { - Value: "my-entitlement-1", - Display: "Entitlement 1", - Type: "main-entitlement", - Primary: true, - }, - { - Value: "my-entitlement-2", - Display: "Entitlement 2", - Type: "secondary-entitlement", - Primary: false, - }, - }, + } }, }, { name: "scoped externalID", - buildUserID: func() string { - // create user without provisioning domain - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + setup: func(t *testing.T) testCase { + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, gofakeit.Username())) require.NoError(t, err) - - // set provisioning domain of service user - setProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID, "fooBar") - - // set externalID for provisioning domain + callingUserId, callingUserPat, err := Instance.CreateMachineUserPATWithMembership(CTX, "ORG_OWNER") + require.NoError(t, err) + setProvisioningDomain(t, callingUserId, "fooBar") setAndEnsureMetadata(t, createdUser.ID, "urn:zitadel:scim:fooBar:externalId", "100-scopedExternalId") - return createdUser.ID - }, - cleanup: func(userID string) { - _, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID}) - require.NoError(t, err) - - removeProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID) - }, - want: &resources.ScimUser{ - ExternalID: "100-scopedExternalId", + return testCase{ + ctx: integration.WithAuthorizationToken(CTX, callingUserPat), + userID: createdUser.ID, + want: &resources.ScimUser{ + ExternalID: "100-scopedExternalId", + }, + } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := tt.ctx - if ctx == nil { - ctx = CTX + ttt := tt.setup(t) + if ttt.userID == "" { + ttt.userID = Instance.CreateHumanUser(CTX).UserId } - - var userID string - if tt.buildUserID != nil { - userID = tt.buildUserID() - } else { - createUserResp := Instance.CreateHumanUser(CTX) - userID = createUserResp.UserId + if ttt.ctx == nil { + ttt.ctx = CTX } - - orgID := tt.orgID - if orgID == "" { - orgID = Instance.DefaultOrg.Id + if ttt.orgID == "" { + ttt.orgID = Instance.DefaultOrg.Id } - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) - var fetchedUser *resources.ScimUser - var err error - require.EventuallyWithT(t, func(ttt *assert.CollectT) { - fetchedUser, err = Instance.Client.SCIM.Users.Get(ctx, orgID, userID) - if tt.wantErr { - statusCode := tt.errorStatus + require.EventuallyWithT(t, func(collect *assert.CollectT) { + fetchedUser, err := Instance.Client.SCIM.Users.Get(ttt.ctx, ttt.orgID, ttt.userID) + if ttt.wantErr { + statusCode := ttt.errorStatus if statusCode == 0 { statusCode = http.StatusBadRequest } - - scim.RequireScimError(ttt, statusCode, err) + scim.RequireScimError(collect, statusCode, err) return } - - assert.Equal(ttt, userID, fetchedUser.ID) - assert.EqualValues(ttt, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, fetchedUser.Schemas) - assert.Equal(ttt, schemas.ScimResourceTypeSingular("User"), fetchedUser.Resource.Meta.ResourceType) - assert.Equal(ttt, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, orgID, "Users", fetchedUser.ID), fetchedUser.Resource.Meta.Location) - assert.Nil(ttt, fetchedUser.Password) - if !test.PartiallyDeepEqual(tt.want, fetchedUser) { - ttt.Errorf("GetUser() got = %#v, want %#v", fetchedUser, tt.want) + if !assert.NoError(collect, err) { + scim.RequireScimError(collect, http.StatusNotFound, err) + return + } + assert.Equal(collect, ttt.userID, fetchedUser.ID) + assert.EqualValues(collect, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, fetchedUser.Schemas) + assert.Equal(collect, schemas.ScimResourceTypeSingular("User"), fetchedUser.Resource.Meta.ResourceType) + assert.Equal(collect, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, ttt.orgID, "Users", fetchedUser.ID), fetchedUser.Resource.Meta.Location) + assert.Nil(collect, fetchedUser.Password) + if !test.PartiallyDeepEqual(ttt.want, fetchedUser) { + collect.Errorf("GetUser() got = %#v, want %#v", fetchedUser, ttt.want) } }, retryDuration, tick) - - if tt.cleanup != nil { - tt.cleanup(fetchedUser.ID) - } }) } } diff --git a/internal/api/scim/integration_test/users_list_test.go b/internal/api/scim/integration_test/users_list_test.go index 8c6ccb80ef..81fbf1a5bc 100644 --- a/internal/api/scim/integration_test/users_list_test.go +++ b/internal/api/scim/integration_test/users_list_test.go @@ -23,15 +23,6 @@ var totalCountOfHumanUsers = 13 /* func TestListUser(t *testing.T) { createdUserIDs := createUsers(t, CTX, Instance.DefaultOrg.Id) - defer func() { - // only the full user needs to be deleted, all others have random identification data - // fullUser is always the first one. - _, err := Instance.Client.UserV2.DeleteUser(CTX, &user_v2.DeleteUserRequest{ - UserId: createdUserIDs[0], - }) - require.NoError(t, err) - }() - // secondary organization with same set of users, // these should never be modified. // This allows testing list requests without filters. @@ -451,7 +442,7 @@ func createUsers(t *testing.T, ctx context.Context, orgID string) []string { // create the full scim user if on primary org if orgID == Instance.DefaultOrg.Id { - fullUserCreatedResp, err := Instance.Client.SCIM.Users.Create(ctx, orgID, fullUserJson) + fullUserCreatedResp, err := Instance.Client.SCIM.Users.Create(ctx, orgID, withUsername(fullUserJson, gofakeit.Username())) require.NoError(t, err) createdUserIDs = append(createdUserIDs, fullUserCreatedResp.ID) count-- diff --git a/internal/api/scim/integration_test/users_replace_test.go b/internal/api/scim/integration_test/users_replace_test.go index 1c99592b01..7896ed4e00 100644 --- a/internal/api/scim/integration_test/users_replace_test.go +++ b/internal/api/scim/integration_test/users_replace_test.go @@ -5,11 +5,13 @@ package integration_test import ( "context" _ "embed" + "fmt" "net/http" "path" "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" @@ -20,7 +22,6 @@ import ( "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/internal/test" "github.com/zitadel/zitadel/pkg/grpc/management" - "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var ( @@ -199,28 +200,28 @@ func TestReplaceUser(t *testing.T) { }, { name: "not authenticated", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), ctx: context.Background(), wantErr: true, errorStatus: http.StatusUnauthorized, }, { name: "no permissions", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), wantErr: true, errorStatus: http.StatusNotFound, }, { name: "another org", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), replaceUserOrgID: SecondaryOrganization.OrganizationId, wantErr: true, errorStatus: http.StatusNotFound, }, { name: "another org with permissions", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), replaceUserOrgID: SecondaryOrganization.OrganizationId, ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), wantErr: true, @@ -230,14 +231,9 @@ func TestReplaceUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // use iam owner => we don't want to test permissions of the create endpoint. - createdUser, err := Instance.Client.SCIM.Users.Create(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), Instance.DefaultOrg.Id, fullUserJson) + createdUser, err := Instance.Client.SCIM.Users.Create(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), Instance.DefaultOrg.Id, withUsername(fullUserJson, gofakeit.Username())) require.NoError(t, err) - defer func() { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - assert.NoError(t, err) - }() - ctx := tt.ctx if ctx == nil { ctx = CTX @@ -294,10 +290,11 @@ func TestReplaceUser(t *testing.T) { func TestReplaceUser_removeOldMetadata(t *testing.T) { // ensure old metadata is removed correctly - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + username := gofakeit.Username() + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) require.NoError(t, err) - _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserJson) + _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, withUsername(minimalUserJson, username)) require.NoError(t, err) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) @@ -312,20 +309,17 @@ func TestReplaceUser_removeOldMetadata(t *testing.T) { for i := range md.Result { mdMap[md.Result[i].Key] = string(md.Result[i].Value) } - - test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", "[{\"value\":\"user1@example.com\",\"primary\":true}]") + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", fmt.Sprintf("[{\"value\":\"%s@example.com\",\"primary\":true}]", username)) }, retryDuration, tick) - - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) } func TestReplaceUser_emailType(t *testing.T) { // ensure old metadata is removed correctly - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, gofakeit.Username())) require.NoError(t, err) - _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserWithEmailTypeReplaceJson) + replacedUsername := gofakeit.Username() + _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, withUsername(minimalUserWithEmailTypeReplaceJson, replacedUsername)) require.NoError(t, err) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) @@ -341,28 +335,26 @@ func TestReplaceUser_emailType(t *testing.T) { mdMap[md.Result[i].Key] = string(md.Result[i].Value) } - test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", "[{\"value\":\"user1-minimal-replaced@example.com\",\"primary\":true,\"type\":\"work\"}]") + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", fmt.Sprintf("[{\"value\":\"%s@example.com\",\"primary\":true,\"type\":\"work\"}]", replacedUsername)) }, retryDuration, tick) - - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) } func TestReplaceUser_scopedExternalID(t *testing.T) { - // create user without provisioning domain set - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, gofakeit.Username())) require.NoError(t, err) - + callingUserId, callingUserPat, err := Instance.CreateMachineUserPATWithMembership(CTX, "ORG_OWNER") + require.NoError(t, err) + ctx := integration.WithAuthorizationToken(CTX, callingUserPat) // set provisioning domain of service user - setProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID, "fooBazz") + setProvisioningDomain(t, callingUserId, "fooBazz") // replace the user with provisioning domain set - _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserWithExternalIDJson) + _, err = Instance.Client.SCIM.Users.Replace(ctx, Instance.DefaultOrg.Id, createdUser.ID, minimalUserWithExternalIDJson) require.NoError(t, err) - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) require.EventuallyWithT(t, func(tt *assert.CollectT) { - md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ + md, err := Instance.Client.Mgmt.ListUserMetadata(ctx, &management.ListUserMetadataRequest{ Id: createdUser.ID, }) require.NoError(tt, err) @@ -376,9 +368,4 @@ func TestReplaceUser_scopedExternalID(t *testing.T) { test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984") test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:fooBazz:externalId", "replaced-external-id") }, retryDuration, tick) - - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) - - removeProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID) } diff --git a/internal/api/scim/integration_test/users_update_test.go b/internal/api/scim/integration_test/users_update_test.go index 77e55bac60..f8a65a8a69 100644 --- a/internal/api/scim/integration_test/users_update_test.go +++ b/internal/api/scim/integration_test/users_update_test.go @@ -5,11 +5,13 @@ package integration_test import ( "context" _ "embed" + "encoding/json" "fmt" "net/http" "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" @@ -19,7 +21,6 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/internal/test" - "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var ( @@ -34,15 +35,7 @@ func init() { } func TestUpdateUser(t *testing.T) { - fullUserCreated, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) - require.NoError(t, err) - - defer func() { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: fullUserCreated.ID}) - require.NoError(t, err) - }() - - tests := []struct { + type testCase struct { name string body []byte ctx context.Context @@ -52,215 +45,297 @@ func TestUpdateUser(t *testing.T) { wantErr bool scimErrorType string errorStatus int + } + tests := []struct { + name string + setup func(t *testing.T) testCase }{ { - name: "not authenticated", - ctx: context.Background(), - body: minimalUserUpdateJson, - wantErr: true, - errorStatus: http.StatusUnauthorized, + name: "not authenticated", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + ctx: context.Background(), + body: minimalUserUpdateJson, + wantErr: true, + errorStatus: http.StatusUnauthorized, + } + }, }, { - name: "no permissions", - ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), - body: minimalUserUpdateJson, - wantErr: true, - errorStatus: http.StatusNotFound, + name: "no permissions", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + body: minimalUserUpdateJson, + wantErr: true, + errorStatus: http.StatusNotFound, + } + }, }, { - name: "other org", - orgID: SecondaryOrganization.OrganizationId, - body: minimalUserUpdateJson, - wantErr: true, - errorStatus: http.StatusNotFound, + name: "other org", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + orgID: SecondaryOrganization.OrganizationId, + body: minimalUserUpdateJson, + wantErr: true, + errorStatus: http.StatusNotFound, + } + }, }, { - name: "other org with permissions", - ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), - orgID: SecondaryOrganization.OrganizationId, - body: minimalUserUpdateJson, - wantErr: true, - errorStatus: http.StatusNotFound, + name: "other org with permissions", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + orgID: SecondaryOrganization.OrganizationId, + body: minimalUserUpdateJson, + wantErr: true, + errorStatus: http.StatusNotFound, + } + }, }, { - name: "invalid patch json", - body: simpleReplacePatchBody("nickname", "10"), - wantErr: true, - scimErrorType: "invalidValue", + name: "invalid patch json", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: simpleReplacePatchBody("nickname", "10"), + wantErr: true, + scimErrorType: "invalidValue", + } + }, }, { - name: "password complexity violation", - body: simpleReplacePatchBody("password", `"fooBar"`), - wantErr: true, - scimErrorType: "invalidValue", + name: "password complexity violation", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: simpleReplacePatchBody("password", `"fooBar"`), + wantErr: true, + scimErrorType: "invalidValue", + } + }, }, { - name: "invalid profile url", - body: simpleReplacePatchBody("profileUrl", `"ftp://example.com/profiles"`), - wantErr: true, - scimErrorType: "invalidValue", + name: "invalid profile url", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: simpleReplacePatchBody("profileUrl", `"ftp://example.com/profiles"`), + wantErr: true, + scimErrorType: "invalidValue", + } + }, }, { - name: "invalid time zone", - body: simpleReplacePatchBody("timezone", `"foobar"`), - wantErr: true, - scimErrorType: "invalidValue", + name: "invalid time zone", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: simpleReplacePatchBody("timezone", `"foobar"`), + wantErr: true, + scimErrorType: "invalidValue", + } + }, }, { - name: "invalid locale", - body: simpleReplacePatchBody("locale", `"foobar"`), - wantErr: true, - scimErrorType: "invalidValue", + name: "invalid locale", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: simpleReplacePatchBody("locale", `"foobar"`), + wantErr: true, + scimErrorType: "invalidValue", + } + }, }, { - name: "unknown user id", - body: simpleReplacePatchBody("nickname", `"foo"`), - userID: "fooBar", - wantErr: true, - errorStatus: http.StatusNotFound, + name: "unknown user id", + setup: func(t *testing.T) testCase { + return testCase{ + body: simpleReplacePatchBody("nickname", `"foo"`), + userID: "fooBar", + wantErr: true, + errorStatus: http.StatusNotFound, + } + }, }, { name: "full", - body: fullUserUpdateJson, - want: &resources.ScimUser{ - ExternalID: "fooBAR", - UserName: "bjensen@example.com", - Name: &resources.ScimUserName{ - Formatted: "replaced-display-name", - FamilyName: "added-family-name", - GivenName: "added-given-name", - MiddleName: "added-middle-name-2", - HonorificPrefix: "added-honorific-prefix", - HonorificSuffix: "replaced-honorific-suffix", - }, - DisplayName: "replaced-display-name", - NickName: "", - ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), - Emails: []*resources.ScimEmail{ - { - Value: "bjensen@example.com", - Type: "work", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: withUsername(fullUserUpdateJson, username), + want: &resources.ScimUser{ + ExternalID: "fooBAR", + UserName: username + "@example.com", + Name: &resources.ScimUserName{ + Formatted: "replaced-display-name", + FamilyName: "added-family-name", + GivenName: "added-given-name", + MiddleName: "added-middle-name-2", + HonorificPrefix: "added-honorific-prefix", + HonorificSuffix: "replaced-honorific-suffix", + }, + DisplayName: "replaced-display-name", + NickName: "", + ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), + Emails: []*resources.ScimEmail{ + { + Value: username + "@example.com", + Type: "work", + }, + { + Value: username + "+1@example.com", + Type: "home", + }, + { + Value: username + "+2@example.com", + Primary: true, + Type: "home", + }, + }, + Addresses: []*resources.ScimAddress{ + { + Type: "replaced-work", + StreetAddress: "replaced-100 Universal City Plaza", + Locality: "replaced-Hollywood", + Region: "replaced-CA", + PostalCode: "replaced-91608", + Country: "replaced-USA", + Formatted: "replaced-100 Universal City Plaza\nHollywood, CA 91608 USA", + Primary: true, + }, + }, + PhoneNumbers: []*resources.ScimPhoneNumber{ + { + Value: "+41711234567", + Primary: true, + }, + }, + Ims: []*resources.ScimIms{ + { + Value: "someaimhandle", + Type: "aim", + }, + { + Value: "twitterhandle", + Type: "", + }, + }, + Photos: []*resources.ScimPhoto{ + { + Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), + Type: "photo", + }, + }, + Roles: nil, + Entitlements: []*resources.ScimEntitlement{ + { + Value: "my-entitlement-1", + Display: "added-entitlement-1", + Type: "added-entitlement-1", + Primary: false, + }, + { + Value: "my-entitlement-2", + Display: "Entitlement 2", + Type: "secondary-entitlement", + Primary: false, + }, + { + Value: "added-entitlement-1", + Primary: false, + }, + { + Value: "added-entitlement-2", + Primary: false, + }, + { + Value: "added-entitlement-3", + Primary: true, + }, + }, + Title: "Tour Guide", + PreferredLanguage: language.MustParse("en"), + Locale: "en-US", + Timezone: "America/Los_Angeles", + Active: schemas.NewRelaxedBool(true), }, - { - Value: "babs@jensen.org", - Type: "home", - }, - { - Value: "babs@example.com", - Primary: true, - Type: "home", - }, - }, - Addresses: []*resources.ScimAddress{ - { - Type: "replaced-work", - StreetAddress: "replaced-100 Universal City Plaza", - Locality: "replaced-Hollywood", - Region: "replaced-CA", - PostalCode: "replaced-91608", - Country: "replaced-USA", - Formatted: "replaced-100 Universal City Plaza\nHollywood, CA 91608 USA", - Primary: true, - }, - }, - PhoneNumbers: []*resources.ScimPhoneNumber{ - { - Value: "+41711234567", - Primary: true, - }, - }, - Ims: []*resources.ScimIms{ - { - Value: "someaimhandle", - Type: "aim", - }, - { - Value: "twitterhandle", - Type: "", - }, - }, - Photos: []*resources.ScimPhoto{ - { - Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), - Type: "photo", - }, - }, - Roles: nil, - Entitlements: []*resources.ScimEntitlement{ - { - Value: "my-entitlement-1", - Display: "added-entitlement-1", - Type: "added-entitlement-1", - Primary: false, - }, - { - Value: "my-entitlement-2", - Display: "Entitlement 2", - Type: "secondary-entitlement", - Primary: false, - }, - { - Value: "added-entitlement-1", - Primary: false, - }, - { - Value: "added-entitlement-2", - Primary: false, - }, - { - Value: "added-entitlement-3", - Primary: true, - }, - }, - Title: "Tour Guide", - PreferredLanguage: language.MustParse("en-US"), - Locale: "en-US", - Timezone: "America/Los_Angeles", - Active: schemas.NewRelaxedBool(true), + } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.ctx == nil { - tt.ctx = CTX + ttt := tt.setup(t) + if ttt.orgID == "" { + ttt.orgID = Instance.DefaultOrg.Id } - - if tt.orgID == "" { - tt.orgID = Instance.DefaultOrg.Id + if ttt.ctx == nil { + ttt.ctx = CTX } - - if tt.userID == "" { - tt.userID = fullUserCreated.ID - } - - err := Instance.Client.SCIM.Users.Update(tt.ctx, tt.orgID, tt.userID, tt.body) - - if tt.wantErr { + err := Instance.Client.SCIM.Users.Update(ttt.ctx, ttt.orgID, ttt.userID, ttt.body) + if ttt.wantErr { require.Error(t, err) - - statusCode := tt.errorStatus + statusCode := ttt.errorStatus if statusCode == 0 { statusCode = http.StatusBadRequest } - scimErr := scim.RequireScimError(t, statusCode, err) - assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType) + assert.Equal(t, ttt.scimErrorType, scimErr.Error.ScimType) return } require.NoError(t, err) - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) - require.EventuallyWithT(t, func(ttt *assert.CollectT) { - fetchedUser, err := Instance.Client.SCIM.Users.Get(tt.ctx, tt.orgID, fullUserCreated.ID) - require.NoError(ttt, err) - + require.EventuallyWithT(t, func(collect *assert.CollectT) { + fetchedUser, err := Instance.Client.SCIM.Users.Get(ttt.ctx, ttt.orgID, ttt.userID) + if !assert.NoError(collect, err) { + return + } fetchedUser.Resource = nil fetchedUser.ID = "" - if tt.want != nil && !test.PartiallyDeepEqual(tt.want, fetchedUser) { - ttt.Errorf("got = %#v, want = %#v", fetchedUser, tt.want) - } + fetched, err := json.Marshal(fetchedUser) + require.NoError(collect, err) + want, err := json.Marshal(ttt.want) + require.NoError(collect, err) + assert.JSONEq(collect, string(want), string(fetched)) }, retryDuration, tick) }) } diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index 6506ae35c7..dbf97e0eae 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -262,7 +262,7 @@ func (h *UsersHandler) queryUserDependencies(ctx context.Context, userID string) grants, err := h.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, - }, true) + }, true, nil) if err != nil { return nil, nil, err } diff --git a/internal/api/scim/resources/user_metadata.go b/internal/api/scim/resources/user_metadata.go index 3e018507fe..b758117ce8 100644 --- a/internal/api/scim/resources/user_metadata.go +++ b/internal/api/scim/resources/user_metadata.go @@ -45,7 +45,7 @@ func (h *UsersHandler) queryMetadataForUsers(ctx context.Context, userIds []stri func (h *UsersHandler) queryMetadataForUser(ctx context.Context, id string) (map[metadata.ScopedKey][]byte, error) { queries := h.buildMetadataQueries(ctx) - md, err := h.query.SearchUserMetadata(ctx, false, id, queries, false) + md, err := h.query.SearchUserMetadata(ctx, false, id, queries, nil) if err != nil { return nil, err } diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go index 6202c38c8b..967d79c1a9 100644 --- a/internal/api/ui/login/external_provider_handler.go +++ b/internal/api/ui/login/external_provider_handler.go @@ -48,6 +48,18 @@ const ( tmplExternalNotFoundOption = "externalnotfoundoption" ) +var ( + samlFormPost = template.Must(template.New("saml-post-form").Parse(` +
+{{range $key, $value := .Fields}} + +{{end}} + +
+ +`)) +) + type externalIDPData struct { IDPConfigID string `schema:"idpConfigID"` } @@ -201,15 +213,21 @@ func (l *Login) handleIDP(w http.ResponseWriter, r *http.Request, authReq *domai l.externalAuthFailed(w, r, authReq, err) return } - - content, redirect := session.GetAuth(r.Context()) - if redirect { - http.Redirect(w, r, content, http.StatusFound) + auth, err := session.GetAuth(r.Context()) + if err != nil { + l.renderInternalError(w, r, authReq, err) return } - _, err = w.Write([]byte(content)) - if err != nil { - l.renderError(w, r, authReq, err) + switch a := auth.(type) { + case *idp.RedirectAuth: + http.Redirect(w, r, a.RedirectURL, http.StatusFound) + return + case *idp.FormAuth: + err = samlFormPost.Execute(w, a) + if err != nil { + l.renderError(w, r, authReq, err) + return + } return } } @@ -1242,7 +1260,8 @@ func (l *Login) appendUserGrants(ctx context.Context, userGrants []*domain.UserG return nil } for _, grant := range userGrants { - _, err := l.command.AddUserGrant(setContext(ctx, resourceOwner), grant, resourceOwner) + grant.ResourceOwner = resourceOwner + _, err := l.command.AddUserGrant(setContext(ctx, resourceOwner), grant, nil) if err != nil { return err } diff --git a/internal/auth/repository/eventsourcing/repository.go b/internal/auth/repository/eventsourcing/repository.go index 16bee6e7a4..81dc8abd5c 100644 --- a/internal/auth/repository/eventsourcing/repository.go +++ b/internal/auth/repository/eventsourcing/repository.go @@ -125,7 +125,7 @@ func (q queryViewWrapper) UserGrantsByProjectAndUserID(ctx context.Context, proj return nil, err } queries := &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantUserID, userGrantProjectID, activeQuery}} - grants, err := q.Queries.UserGrants(ctx, queries, true) + grants, err := q.Queries.UserGrants(ctx, queries, true, nil) if err != nil { return nil, err } diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index b707631c22..d6c14afea3 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "fmt" - "slices" "strings" "time" @@ -329,18 +328,10 @@ type openIDKeySet struct { // VerifySignature implements the oidc.KeySet interface // providing an implementation for the keys retrieved directly from Queries func (o *openIDKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) { - keySet := new(jose.JSONWebKeySet) - if authz.GetFeatures(ctx).WebKey { - keySet, err = o.Queries.GetWebKeySet(ctx) - if err != nil { - return nil, err - } - } - legacyKeySet, err := o.Queries.ActivePublicKeys(ctx, time.Now()) + keySet, err := o.Queries.GetWebKeySet(ctx) if err != nil { - return nil, fmt.Errorf("error fetching keys: %w", err) + return nil, err } - appendPublicKeysToWebKeySet(keySet, legacyKeySet) keyID, alg := oidc.GetKeyIDAndAlg(jws) key, err := oidc.FindMatchingKey(keyID, oidc.KeyUseSignature, alg, keySet.Keys...) if err != nil { @@ -348,19 +339,3 @@ func (o *openIDKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSig } return jws.Verify(&key) } - -func appendPublicKeysToWebKeySet(keyset *jose.JSONWebKeySet, pubkeys *query.PublicKeys) { - if pubkeys == nil || len(pubkeys.Keys) == 0 { - return - } - keyset.Keys = slices.Grow(keyset.Keys, len(pubkeys.Keys)) - - for _, key := range pubkeys.Keys { - keyset.Keys = append(keyset.Keys, jose.JSONWebKey{ - Key: key.Key(), - KeyID: key.ID(), - Algorithm: key.Algorithm(), - Use: key.Use().String(), - }) - } -} diff --git a/internal/authz/repository/eventsourcing/view/application.go b/internal/authz/repository/eventsourcing/view/application.go index 8db8ec8e39..7fa920bcfe 100644 --- a/internal/authz/repository/eventsourcing/view/application.go +++ b/internal/authz/repository/eventsourcing/view/application.go @@ -32,7 +32,7 @@ func (v *View) ApplicationByProjecIDAndAppName(ctx context.Context, projectID, a }, } - apps, err := v.Query.SearchApps(ctx, queries, false) + apps, err := v.Query.SearchApps(ctx, queries, nil) if err != nil { return nil, err } diff --git a/internal/command/idp_intent_test.go b/internal/command/idp_intent_test.go index 6cf835f521..e0f4e2ffdb 100644 --- a/internal/command/idp_intent_test.go +++ b/internal/command/idp_intent_test.go @@ -432,9 +432,8 @@ func TestCommands_AuthFromProvider(t *testing.T) { samlRootURL string } type res struct { - content string - redirect bool - err error + auth idp.Auth + err error } tests := []struct { name string @@ -579,8 +578,7 @@ func TestCommands_AuthFromProvider(t *testing.T) { callbackURL: "url", }, res{ - content: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=id", - redirect: true, + auth: &idp.RedirectAuth{RedirectURL: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=id"}, }, }, { @@ -671,8 +669,7 @@ func TestCommands_AuthFromProvider(t *testing.T) { callbackURL: "url", }, res{ - content: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&scope=openid+profile+User.Read&state=id", - redirect: true, + auth: &idp.RedirectAuth{RedirectURL: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&scope=openid+profile+User.Read&state=id"}, }, }, } @@ -686,13 +683,12 @@ func TestCommands_AuthFromProvider(t *testing.T) { _, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL) require.ErrorIs(t, err, tt.res.err) - var content string - var redirect bool + var got idp.Auth if err == nil { - content, redirect = session.GetAuth(tt.args.ctx) + got, err = session.GetAuth(tt.args.ctx) + assert.Equal(t, tt.res.auth, got) + assert.NoError(t, err) } - assert.Equal(t, tt.res.redirect, redirect) - assert.Equal(t, tt.res.content, content) }) } } @@ -811,6 +807,97 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { }, }, }, + { + "saml post auth", + fields{ + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + eventstore: expectEventstore( + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + instance.NewSAMLIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + "name", + []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("key"), + }, + []byte("certificate"), + "", + false, + gu.Ptr(domain.SAMLNameIDFormatUnspecified), + "", + false, + rep_idp.Options{}, + )), + ), + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + instance.NewSAMLIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + "name", + []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + }, []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + "", + false, + gu.Ptr(domain.SAMLNameIDFormatUnspecified), + "", + false, + rep_idp.Options{}, + )), + ), + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + func() eventstore.Command { + success, _ := url.Parse("https://success.url") + failure, _ := url.Parse("https://failure.url") + return idpintent.NewStartedEvent( + context.Background(), + &idpintent.NewAggregate("id", "instance").Aggregate, + success, + failure, + "idp", + nil, + ) + }(), + ), + ), + expectRandomPush( + []eventstore.Command{ + idpintent.NewSAMLRequestEvent( + context.Background(), + &idpintent.NewAggregate("id", "instance").Aggregate, + "request", + ), + }, + ), + ), + idGenerator: mock.ExpectID(t, "id"), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "idp", + callbackURL: "url", + samlRootURL: "samlurl", + }, + res{ + url: "http://localhost:8000/sso", + values: map[string]string{ + "SAMLRequest": "", // generated IDs so not assertable + "RelayState": "id", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -822,16 +909,30 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { _, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL) require.ErrorIs(t, err, tt.res.err) - content, _ := session.GetAuth(tt.args.ctx) - authURL, err := url.Parse(content) + auth, err := session.GetAuth(tt.args.ctx) require.NoError(t, err) + var authURL *url.URL + authFields := make(map[string]string) + + switch a := auth.(type) { + case *idp.RedirectAuth: + authURL, err = url.Parse(a.RedirectURL) + for key, values := range authURL.Query() { + authFields[key] = values[0] + } + require.NoError(t, err) + case *idp.FormAuth: + authURL, err = url.Parse(a.URL) + require.NoError(t, err) + authFields = a.Fields + } + assert.Equal(t, tt.res.url, authURL.Scheme+"://"+authURL.Host+authURL.Path) - query := authURL.Query() for k, v := range tt.res.values { - assert.True(t, query.Has(k)) + assert.Contains(t, authFields, k) if v != "" { - assert.Equal(t, v, query.Get(k)) + assert.Equal(t, v, authFields[k]) } } }) diff --git a/internal/command/instance.go b/internal/command/instance.go index cfafb1d298..8a686262d7 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -217,33 +217,33 @@ func (s *InstanceSetup) generateIDs(idGenerator id.Generator) (err error) { return err } -func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (string, string, *MachineKey, *domain.ObjectDetails, error) { +func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (string, string, *MachineKey, string, *domain.ObjectDetails, error) { if err := setup.generateIDs(c.idGenerator); err != nil { - return "", "", nil, nil, err + return "", "", nil, "", nil, err } ctx = contextWithInstanceSetupInfo(ctx, setup.zitadel.instanceID, setup.zitadel.projectID, setup.zitadel.consoleAppID, c.externalDomain, setup.DefaultLanguage) - validations, pat, machineKey, err := setUpInstance(ctx, c, setup) + validations, pat, machineKey, loginClientPat, err := setUpInstance(ctx, c, setup) if err != nil { - return "", "", nil, nil, err + return "", "", nil, "", nil, err } //nolint:staticcheck cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, validations...) if err != nil { - return "", "", nil, nil, err + return "", "", nil, "", nil, err } _, err = c.eventstore.Push(ctx, cmds...) if err != nil { - return "", "", nil, nil, err + return "", "", nil, "", nil, err } // RolePermissions need to be pushed in separate transaction. // https://github.com/zitadel/zitadel/issues/9293 details, err := c.SynchronizeRolePermission(ctx, setup.zitadel.instanceID, setup.RolePermissionMappings) if err != nil { - return "", "", nil, nil, err + return "", "", nil, "", nil, err } details.ResourceOwner = setup.zitadel.orgID @@ -251,8 +251,12 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str if pat != nil { token = pat.Token } + var loginClientToken string + if loginClientPat != nil { + loginClientToken = loginClientPat.Token + } - return setup.zitadel.instanceID, token, machineKey, details, nil + return setup.zitadel.instanceID, token, machineKey, loginClientToken, details, nil } func contextWithInstanceSetupInfo(ctx context.Context, instanceID, projectID, consoleAppID, externalDomain string, defaultLanguage language.Tag) context.Context { @@ -274,38 +278,38 @@ func contextWithInstanceSetupInfo(ctx context.Context, instanceID, projectID, co ) } -func setUpInstance(ctx context.Context, c *Commands, setup *InstanceSetup) (validations []preparation.Validation, pat *PersonalAccessToken, machineKey *MachineKey, err error) { +func setUpInstance(ctx context.Context, c *Commands, setup *InstanceSetup) (validations []preparation.Validation, pat *PersonalAccessToken, machineKey *MachineKey, loginClientPat *PersonalAccessToken, err error) { instanceAgg := instance.NewAggregate(setup.zitadel.instanceID) validations = setupInstanceElements(instanceAgg, setup) // default organization on setup'd instance - pat, machineKey, err = setupDefaultOrg(ctx, c, &validations, instanceAgg, setup.Org.Name, setup.Org.Machine, setup.Org.Human, setup.zitadel) + pat, machineKey, loginClientPat, err = setupDefaultOrg(ctx, c, &validations, instanceAgg, setup.Org.Name, setup.Org.Machine, setup.Org.Human, setup.Org.LoginClient, setup.zitadel) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // domains if err := setupGeneratedDomain(ctx, c, &validations, instanceAgg, setup.InstanceName); err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } setupCustomDomain(c, &validations, instanceAgg, setup.CustomDomain) // optional setting if set setupMessageTexts(&validations, setup.MessageTexts, instanceAgg) if err := setupQuotas(c, &validations, setup.Quotas, setup.zitadel.instanceID); err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } setupSMTPSettings(c, &validations, setup.SMTPConfiguration, instanceAgg) if err := setupWebKeys(c, &validations, setup.zitadel.instanceID, setup); err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } setupOIDCSettings(c, &validations, setup.OIDCSettings, instanceAgg) setupFeatures(&validations, setup.Features, setup.zitadel.instanceID) setupLimits(c, &validations, limits.NewAggregate(setup.zitadel.limitsID, setup.zitadel.instanceID), setup.Limits) setupRestrictions(c, &validations, restrictions.NewAggregate(setup.zitadel.restrictionsID, setup.zitadel.instanceID, setup.zitadel.instanceID), setup.Restrictions) setupInstanceCreatedMilestone(&validations, setup.zitadel.instanceID) - return validations, pat, machineKey, nil + return validations, pat, machineKey, loginClientPat, nil } func setupInstanceElements(instanceAgg *instance.Aggregate, setup *InstanceSetup) []preparation.Validation { @@ -572,8 +576,9 @@ func setupDefaultOrg(ctx context.Context, name string, machine *AddMachine, human *AddHuman, + loginClient *AddLoginClient, ids ZitadelConfig, -) (pat *PersonalAccessToken, machineKey *MachineKey, err error) { +) (pat *PersonalAccessToken, machineKey *MachineKey, loginClientPat *PersonalAccessToken, err error) { orgAgg := org.NewAggregate(ids.orgID) *validations = append( @@ -582,12 +587,12 @@ func setupDefaultOrg(ctx context.Context, commands.prepareSetDefaultOrg(instanceAgg, ids.orgID), ) - projectOwner, pat, machineKey, err := setupAdmins(commands, validations, instanceAgg, orgAgg, machine, human) + projectOwner, pat, machineKey, loginClientPat, err := setupAdmins(commands, validations, instanceAgg, orgAgg, machine, human, loginClient) if err != nil { - return nil, nil, err + return nil, nil, nil, err } setupMinimalInterfaces(commands, validations, instanceAgg, orgAgg, projectOwner, ids) - return pat, machineKey, nil + return pat, machineKey, loginClientPat, nil } func setupAdmins(commands *Commands, @@ -596,21 +601,22 @@ func setupAdmins(commands *Commands, orgAgg *org.Aggregate, machine *AddMachine, human *AddHuman, -) (owner string, pat *PersonalAccessToken, machineKey *MachineKey, err error) { + loginClient *AddLoginClient, +) (owner string, pat *PersonalAccessToken, machineKey *MachineKey, loginClientPat *PersonalAccessToken, err error) { if human == nil && machine == nil { - return "", nil, nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-z1yi2q2ot7", "Error.Instance.NoAdmin") + return "", nil, nil, nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-z1yi2q2ot7", "Error.Instance.NoAdmin") } if machine != nil && machine.Machine != nil && !machine.Machine.IsZero() { machineUserID, err := commands.idGenerator.Next() if err != nil { - return "", nil, nil, err + return "", nil, nil, nil, err } owner = machineUserID pat, machineKey, err = setupMachineAdmin(commands, validations, machine, orgAgg.ID, machineUserID) if err != nil { - return "", nil, nil, err + return "", nil, nil, nil, err } setupAdminMembers(commands, validations, instanceAgg, orgAgg, machineUserID) @@ -618,7 +624,7 @@ func setupAdmins(commands *Commands, if human != nil { humanUserID, err := commands.idGenerator.Next() if err != nil { - return "", nil, nil, err + return "", nil, nil, nil, err } owner = humanUserID human.ID = humanUserID @@ -629,7 +635,18 @@ func setupAdmins(commands *Commands, setupAdminMembers(commands, validations, instanceAgg, orgAgg, humanUserID) } - return owner, pat, machineKey, nil + if loginClient != nil { + loginClientUserID, err := commands.idGenerator.Next() + if err != nil { + return "", nil, nil, nil, err + } + + loginClientPat, err = setupLoginClient(commands, validations, instanceAgg, loginClient, orgAgg.ID, loginClientUserID) + if err != nil { + return "", nil, nil, nil, err + } + } + return owner, pat, machineKey, loginClientPat, nil } func setupMachineAdmin(commands *Commands, validations *[]preparation.Validation, machine *AddMachine, orgID, userID string) (pat *PersonalAccessToken, machineKey *MachineKey, err error) { @@ -655,9 +672,25 @@ func setupMachineAdmin(commands *Commands, validations *[]preparation.Validation return pat, machineKey, nil } +func setupLoginClient(commands *Commands, validations *[]preparation.Validation, instanceAgg *instance.Aggregate, loginClient *AddLoginClient, orgID, userID string) (pat *PersonalAccessToken, err error) { + *validations = append(*validations, + AddMachineCommand(user.NewAggregate(userID, orgID), loginClient.Machine), + commands.AddInstanceMemberCommand(instanceAgg, userID, domain.RoleIAMLoginClient), + ) + if loginClient.Pat != nil { + pat = NewPersonalAccessToken(orgID, userID, loginClient.Pat.ExpirationDate, loginClient.Pat.Scopes, domain.UserTypeMachine) + pat.TokenID, err = commands.idGenerator.Next() + if err != nil { + return nil, err + } + *validations = append(*validations, prepareAddPersonalAccessToken(pat, commands.keyAlgorithm)) + } + return pat, nil +} + func setupAdminMembers(commands *Commands, validations *[]preparation.Validation, instanceAgg *instance.Aggregate, orgAgg *org.Aggregate, userID string) { *validations = append(*validations, - commands.AddOrgMemberCommand(orgAgg, userID, domain.RoleOrgOwner), + commands.AddOrgMemberCommand(&AddOrgMember{orgAgg.ID, userID, []string{domain.RoleOrgOwner}}), commands.AddInstanceMemberCommand(instanceAgg, userID, domain.RoleIAMOwner), ) } diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index cb12bff828..04f2621705 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -3,11 +3,8 @@ package command import ( "context" - "github.com/muhlemmer/gu" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/feature" @@ -16,31 +13,25 @@ import ( ) type InstanceFeatures struct { - LoginDefaultOrg *bool - TriggerIntrospectionProjections *bool - LegacyIntrospection *bool - UserSchema *bool - TokenExchange *bool - ImprovedPerformance []feature.ImprovedPerformanceType - WebKey *bool - DebugOIDCParentError *bool - OIDCSingleV1SessionTermination *bool - DisableUserTokenEvent *bool - EnableBackChannelLogout *bool - LoginV2 *feature.LoginV2 - PermissionCheckV2 *bool - ConsoleUseV2UserApi *bool + LoginDefaultOrg *bool + UserSchema *bool + TokenExchange *bool + ImprovedPerformance []feature.ImprovedPerformanceType + DebugOIDCParentError *bool + OIDCSingleV1SessionTermination *bool + DisableUserTokenEvent *bool + EnableBackChannelLogout *bool + LoginV2 *feature.LoginV2 + PermissionCheckV2 *bool + ConsoleUseV2UserApi *bool } func (m *InstanceFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && - m.TriggerIntrospectionProjections == nil && - m.LegacyIntrospection == nil && m.UserSchema == nil && m.TokenExchange == nil && // nil check to allow unset improvements m.ImprovedPerformance == nil && - m.WebKey == nil && m.DebugOIDCParentError == nil && m.OIDCSingleV1SessionTermination == nil && m.DisableUserTokenEvent == nil && @@ -57,9 +48,6 @@ func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) if err := c.eventstore.FilterToQueryReducer(ctx, wm); err != nil { return nil, err } - if err := c.setupWebKeyFeature(ctx, wm, f); err != nil { - return nil, err - } commands := wm.setCommands(ctx, f) if len(commands) == 0 { return writeModelToObjectDetails(wm.WriteModel), nil @@ -80,21 +68,6 @@ func prepareSetFeatures(instanceID string, f *InstanceFeatures) preparation.Vali } } -// setupWebKeyFeature generates the initial web keys for the instance, -// if the feature is enabled in the request and the feature wasn't enabled already in the writeModel. -// [Commands.GenerateInitialWebKeys] checks if keys already exist and does nothing if that's the case. -// The default config of a RSA key with 2048 and the SHA256 hasher is assumed. -// Users can customize this after using the webkey/v3 API. -func (c *Commands) setupWebKeyFeature(ctx context.Context, wm *InstanceFeaturesWriteModel, f *InstanceFeatures) error { - if !gu.Value(f.WebKey) || gu.Value(wm.WebKey) { - return nil - } - return c.GenerateInitialWebKeys(ctx, &crypto.WebKeyRSAConfig{ - Bits: crypto.RSABits2048, - Hasher: crypto.RSAHasherSHA256, - }) -} - func (c *Commands) ResetInstanceFeatures(ctx context.Context) (*domain.ObjectDetails, error) { instanceID := authz.GetInstance(ctx).InstanceID() wm := NewInstanceFeaturesWriteModel(instanceID) diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 977a46b6c2..8fe9dd0284 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -67,12 +67,9 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v1.DefaultLoginInstanceEventType, feature_v2.InstanceResetEventType, feature_v2.InstanceLoginDefaultOrgEventType, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, - feature_v2.InstanceLegacyIntrospectionEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, - feature_v2.InstanceWebKeyEventType, feature_v2.InstanceDebugOIDCParentErrorEventType, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, feature_v2.InstanceDisableUserTokenEvent, @@ -95,12 +92,6 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyLoginDefaultOrg: v := value.(bool) features.LoginDefaultOrg = &v - case feature.KeyTriggerIntrospectionProjections: - v := value.(bool) - features.TriggerIntrospectionProjections = &v - case feature.KeyLegacyIntrospection: - v := value.(bool) - features.LegacyIntrospection = &v case feature.KeyTokenExchange: v := value.(bool) features.TokenExchange = &v @@ -110,9 +101,6 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyImprovedPerformance: v := value.([]feature.ImprovedPerformanceType) features.ImprovedPerformance = v - case feature.KeyWebKey: - v := value.(bool) - features.WebKey = &v case feature.KeyDebugOIDCParentError: v := value.(bool) features.DebugOIDCParentError = &v @@ -140,12 +128,9 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan aggregate := feature_v2.NewAggregate(wm.AggregateID, wm.ResourceOwner) cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.InstanceLoginDefaultOrgEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.InstanceTriggerIntrospectionProjectionsEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.InstanceLegacyIntrospectionEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.InstanceTokenExchangeEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.InstanceImprovedPerformanceEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.WebKey, f.WebKey, feature_v2.InstanceWebKeyEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DebugOIDCParentError, f.DebugOIDCParentError, feature_v2.InstanceDebugOIDCParentErrorEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.InstanceDisableUserTokenEvent) diff --git a/internal/command/instance_features_test.go b/internal/command/instance_features_test.go index 02e8896a0c..f0bea9752d 100644 --- a/internal/command/instance_features_test.go +++ b/internal/command/instance_features_test.go @@ -95,42 +95,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ResourceOwner: "instance1", }, }, - { - name: "set TriggerIntrospectionProjections", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - ), - ), - ), - args: args{ctx, &InstanceFeatures{ - TriggerIntrospectionProjections: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "instance1", - }, - }, - { - name: "set LegacyIntrospection", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, - ), - ), - ), - args: args{ctx, &InstanceFeatures{ - LegacyIntrospection: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "instance1", - }, - }, { name: "set UserSchema", eventstore: expectEventstore( @@ -156,12 +120,12 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { expectPushFailed(io.ErrClosedPipe, feature_v2.NewSetEvent[bool]( ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, + feature_v2.InstanceConsoleUseV2UserApi, true, ), ), ), args: args{ctx, &InstanceFeatures{ - LegacyIntrospection: gu.Ptr(true), + ConsoleUseV2UserApi: gu.Ptr(true), }}, wantErr: io.ErrClosedPipe, }, @@ -174,14 +138,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, - ), - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, - ), feature_v2.NewSetEvent[bool]( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, true, @@ -193,11 +149,9 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ), ), args: args{ctx, &InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), - UserSchema: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(true), }}, want: &domain.ObjectDetails{ ResourceOwner: "instance1", @@ -212,10 +166,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, - )), eventFromEventPusher(feature_v2.NewResetEvent( ctx, aggregate, feature_v2.InstanceResetEventType, @@ -224,10 +174,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, - )), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, false, @@ -238,17 +184,11 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, - ), ), ), args: args{ctx, &InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(false), + LoginDefaultOrg: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(false), }}, want: &domain.ObjectDetails{ ResourceOwner: "instance1", diff --git a/internal/command/instance_member.go b/internal/command/instance_member.go index a33635e8f5..0657170f75 100644 --- a/internal/command/instance_member.go +++ b/internal/command/instance_member.go @@ -2,7 +2,7 @@ package command import ( "context" - "reflect" + "slices" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" @@ -69,9 +69,19 @@ func IsInstanceMember(ctx context.Context, filter preparation.FilterToQueryReduc return isMember, nil } -func (c *Commands) AddInstanceMember(ctx context.Context, userID string, roles ...string) (*domain.Member, error) { - instanceAgg := instance.NewAggregate(authz.GetInstance(ctx).InstanceID()) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.AddInstanceMemberCommand(instanceAgg, userID, roles...)) +type AddInstanceMember struct { + InstanceID string + UserID string + Roles []string +} + +func (c *Commands) AddInstanceMember(ctx context.Context, member *AddInstanceMember) (*domain.ObjectDetails, error) { + instanceAgg := instance.NewAggregate(member.InstanceID) + if err := c.checkPermissionUpdateInstanceMember(ctx, member.InstanceID); err != nil { + return nil, err + } + //nolint:staticcheck + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.AddInstanceMemberCommand(instanceAgg, member.UserID, member.Roles...)) if err != nil { return nil, err } @@ -79,33 +89,56 @@ func (c *Commands) AddInstanceMember(ctx context.Context, userID string, roles . if err != nil { return nil, err } - addedMember := NewInstanceMemberWriteModel(ctx, userID) + addedMember := NewInstanceMemberWriteModel(member.InstanceID, member.UserID) err = AppendAndReduce(addedMember, events...) if err != nil { return nil, err } - return memberWriteModelToMember(&addedMember.MemberWriteModel), nil + return writeModelToObjectDetails(&addedMember.WriteModel), nil +} + +type ChangeInstanceMember struct { + InstanceID string + UserID string + Roles []string +} + +func (i *ChangeInstanceMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if i.InstanceID == "" || i.UserID == "" || len(i.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "INSTANCE-LiaZi", "Errors.IAM.MemberInvalid") + } + if len(domain.CheckForInvalidRoles(i.Roles, domain.IAMRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "INSTANCE-3m9fs", "Errors.IAM.MemberInvalid") + } + return nil } // ChangeInstanceMember updates an existing member -func (c *Commands) ChangeInstanceMember(ctx context.Context, member *domain.Member) (*domain.Member, error) { - if !member.IsIAMValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-LiaZi", "Errors.IAM.MemberInvalid") - } - if len(domain.CheckForInvalidRoles(member.Roles, domain.IAMRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-3m9fs", "Errors.IAM.MemberInvalid") - } - - existingMember, err := c.instanceMemberWriteModelByID(ctx, member.UserID) - if err != nil { +func (c *Commands) ChangeInstanceMember(ctx context.Context, member *ChangeInstanceMember) (*domain.ObjectDetails, error) { + if err := member.IsValid(c.zitadelRoles); err != nil { return nil, err } - if reflect.DeepEqual(existingMember.Roles, member.Roles) { - return nil, zerrors.ThrowPreconditionFailed(nil, "INSTANCE-LiaZi", "Errors.IAM.Member.RolesNotChanged") + existingMember, err := c.instanceMemberWriteModelByID(ctx, member.InstanceID, member.UserID) + if err != nil { + return nil, err } - instanceAgg := InstanceAggregateFromWriteModel(&existingMember.MemberWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, instance.NewMemberChangedEvent(ctx, instanceAgg, member.UserID, member.Roles...)) + if !existingMember.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "INSTANCE-D8JxR", "Errors.NotFound") + } + if err := c.checkPermissionUpdateInstanceMember(ctx, existingMember.AggregateID); err != nil { + return nil, err + } + if slices.Compare(existingMember.Roles, member.Roles) == 0 { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + pushedEvents, err := c.eventstore.Push(ctx, + instance.NewMemberChangedEvent(ctx, + InstanceAggregateFromWriteModel(&existingMember.WriteModel), + member.UserID, + member.Roles..., + ), + ) if err != nil { return nil, err } @@ -114,34 +147,40 @@ func (c *Commands) ChangeInstanceMember(ctx context.Context, member *domain.Memb return nil, err } - return memberWriteModelToMember(&existingMember.MemberWriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } -func (c *Commands) RemoveInstanceMember(ctx context.Context, userID string) (*domain.ObjectDetails, error) { +func (c *Commands) RemoveInstanceMember(ctx context.Context, instanceID, userID string) (*domain.ObjectDetails, error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-LiaZi", "Errors.IDMissing") } - memberWriteModel, err := c.instanceMemberWriteModelByID(ctx, userID) - if err != nil && !zerrors.IsNotFound(err) { - return nil, err - } - if zerrors.IsNotFound(err) { - // empty response because we have no data that match the request - return &domain.ObjectDetails{}, nil - } - - instanceAgg := InstanceAggregateFromWriteModel(&memberWriteModel.MemberWriteModel.WriteModel) - removeEvent := c.removeInstanceMember(ctx, instanceAgg, userID, false) - pushedEvents, err := c.eventstore.Push(ctx, removeEvent) + existingMember, err := c.instanceMemberWriteModelByID(ctx, instanceID, userID) if err != nil { return nil, err } - err = AppendAndReduce(memberWriteModel, pushedEvents...) + if err := c.checkPermissionDeleteInstanceMember(ctx, instanceID); err != nil { + return nil, err + } + if !existingMember.State.Exists() { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + + pushedEvents, err := c.eventstore.Push(ctx, + c.removeInstanceMember(ctx, + InstanceAggregateFromWriteModel(&existingMember.WriteModel), + userID, + false, + ), + ) + if err != nil { + return nil, err + } + err = AppendAndReduce(existingMember, pushedEvents...) if err != nil { return nil, err } - return writeModelToObjectDetails(&memberWriteModel.MemberWriteModel.WriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) removeInstanceMember(ctx context.Context, instanceAgg *eventstore.Aggregate, userID string, cascade bool) eventstore.Command { @@ -155,19 +194,15 @@ func (c *Commands) removeInstanceMember(ctx context.Context, instanceAgg *events } } -func (c *Commands) instanceMemberWriteModelByID(ctx context.Context, userID string) (member *InstanceMemberWriteModel, err error) { +func (c *Commands) instanceMemberWriteModelByID(ctx context.Context, instanceID, userID string) (member *InstanceMemberWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel := NewInstanceMemberWriteModel(ctx, userID) + writeModel := NewInstanceMemberWriteModel(instanceID, userID) err = c.eventstore.FilterToQueryReducer(ctx, writeModel) if err != nil { return nil, err } - if writeModel.State == domain.MemberStateUnspecified || writeModel.State == domain.MemberStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "INSTANCE-D8JxR", "Errors.NotFound") - } - return writeModel, nil } diff --git a/internal/command/instance_member_model.go b/internal/command/instance_member_model.go index e987a41d97..9cd2ca26a0 100644 --- a/internal/command/instance_member_model.go +++ b/internal/command/instance_member_model.go @@ -1,9 +1,6 @@ package command import ( - "context" - - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/instance" ) @@ -12,12 +9,12 @@ type InstanceMemberWriteModel struct { MemberWriteModel } -func NewInstanceMemberWriteModel(ctx context.Context, userID string) *InstanceMemberWriteModel { +func NewInstanceMemberWriteModel(instanceID, userID string) *InstanceMemberWriteModel { return &InstanceMemberWriteModel{ MemberWriteModel{ WriteModel: eventstore.WriteModel{ - AggregateID: authz.GetInstance(ctx).InstanceID(), - ResourceOwner: authz.GetInstance(ctx).InstanceID(), + AggregateID: instanceID, + ResourceOwner: instanceID, }, UserID: userID, }, diff --git a/internal/command/instance_member_test.go b/internal/command/instance_member_test.go index 8d254c56bc..264e7d16aa 100644 --- a/internal/command/instance_member_test.go +++ b/internal/command/instance_member_test.go @@ -10,24 +10,22 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) -func TestCommandSide_AddIAMMember(t *testing.T) { +func TestCommandSide_AddInstanceMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - userID string - roles []string + member *AddInstanceMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -39,28 +37,28 @@ func TestCommandSide_AddIAMMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), + member: &AddInstanceMember{}, }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: zerrors.IsInternal, }, }, { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - userID: "user1", - roles: []string{"IAM_OWNER"}, + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -69,10 +67,10 @@ func TestCommandSide_AddIAMMember(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -80,9 +78,11 @@ func TestCommandSide_AddIAMMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - userID: "user1", - roles: []string{"IAM_OWNER"}, + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, }, res: res{ err: zerrors.IsPreconditionFailed, @@ -91,8 +91,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { { name: "member already exists, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -118,6 +117,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -125,9 +125,11 @@ func TestCommandSide_AddIAMMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - userID: "user1", - roles: []string{"IAM_OWNER"}, + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -136,8 +138,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { { name: "member add uniqueconstraint err, already exists", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -163,6 +164,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -170,9 +172,11 @@ func TestCommandSide_AddIAMMember(t *testing.T) { }, }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - userID: "user1", - roles: []string{"IAM_OWNER"}, + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -181,8 +185,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { { name: "member add, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithInstanceID( "INSTANCE", @@ -209,6 +212,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -216,30 +220,49 @@ func TestCommandSide_AddIAMMember(t *testing.T) { }, }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - userID: "user1", - roles: []string{"IAM_OWNER"}, + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, }, res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - InstanceID: "INSTANCE", - ResourceOwner: "INSTANCE", - AggregateID: "INSTANCE", - }, - UserID: "user1", - Roles: []string{"IAM_OWNER"}, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", }, }, }, + { + name: "member add, no permission", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: "IAM_OWNER", + }, + }, + }, + args: args{ + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.AddInstanceMember(tt.args.ctx, tt.args.userID, tt.args.roles...) + got, err := r.AddInstanceMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -247,24 +270,23 @@ func TestCommandSide_AddIAMMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } } -func TestCommandSide_ChangeIAMMember(t *testing.T) { +func TestCommandSide_ChangeInstanceMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - instanceID string - member *domain.Member + member *ChangeInstanceMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -276,13 +298,11 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{}, + member: &ChangeInstanceMember{}, }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -291,15 +311,14 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - UserID: "user1", - Roles: []string{"IAM_OWNER"}, + member: &ChangeInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, }, }, res: res{ @@ -309,10 +328,10 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { { name: "member not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -320,10 +339,10 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - UserID: "user1", - Roles: []string{"IAM_OWNER"}, + member: &ChangeInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, }, }, res: res{ @@ -333,8 +352,7 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { { name: "member not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewMemberAddedEvent(context.Background(), @@ -345,6 +363,7 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleIAMOwner, @@ -352,21 +371,22 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - UserID: "user1", - Roles: []string{"IAM_OWNER"}, + member: &ChangeInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, }, }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, }, }, { name: "member change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewMemberAddedEvent(context.Background(), @@ -384,6 +404,7 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -394,32 +415,62 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - UserID: "user1", - Roles: []string{"IAM_OWNER", "IAM_OWNER_VIEWER"}, + member: &ChangeInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER", "IAM_OWNER_VIEWER"}, }, }, res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "INSTANCE", - AggregateID: "INSTANCE", - InstanceID: "INSTANCE", - }, - UserID: "user1", - Roles: []string{"IAM_OWNER", "IAM_OWNER_VIEWER"}, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", }, }, }, + { + name: "member change, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewMemberAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "user1", + []string{"IAM_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: "IAM_OWNER", + }, + { + Role: "IAM_OWNER_VIEWER", + }, + }, + }, + args: args{ + member: &ChangeInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.ChangeInstanceMember(tt.args.ctx, tt.args.member) + got, err := r.ChangeInstanceMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -427,18 +478,18 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } } -func TestCommandSide_RemoveIAMMember(t *testing.T) { +func TestCommandSide_RemoveInstanceMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context instanceID string userID string } @@ -455,13 +506,12 @@ func TestCommandSide_RemoveIAMMember(t *testing.T) { { name: "invalid member userid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - userID: "", + instanceID: "INSTANCE", + userID: "", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -470,24 +520,25 @@ func TestCommandSide_RemoveIAMMember(t *testing.T) { { name: "member not existing, empty object details result", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - userID: "user1", + instanceID: "INSTANCE", + userID: "user1", }, res: res{ - want: &domain.ObjectDetails{}, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, }, }, { name: "member remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewMemberAddedEvent(context.Background(), @@ -504,10 +555,11 @@ func TestCommandSide_RemoveIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - userID: "user1", + instanceID: "INSTANCE", + userID: "user1", }, res: res{ want: &domain.ObjectDetails{ @@ -515,13 +567,38 @@ func TestCommandSide_RemoveIAMMember(t *testing.T) { }, }, }, + { + name: "member remove, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewMemberAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "user1", + []string{"IAM_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + instanceID: "INSTANCE", + userID: "user1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } - got, err := r.RemoveInstanceMember(tt.args.ctx, tt.args.userID) + got, err := r.RemoveInstanceMember(context.Background(), tt.args.instanceID, tt.args.userID) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index 2b82818a7e..b40bba19af 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -129,7 +129,7 @@ func oidcAppEvents(ctx context.Context, orgID, projectID, id, name, clientID str } } -func orgFilters(orgID string, machine, human bool) []expect { +func orgFilters(orgID string, machine, human, loginClient bool) []expect { filters := []expect{ expectFilter(), expectFilter( @@ -144,13 +144,17 @@ func orgFilters(orgID string, machine, human bool) []expect { filters = append(filters, humanFilters(orgID)...) filters = append(filters, adminMemberFilters(orgID, "USER")...) } + if loginClient { + filters = append(filters, loginClientFilters(orgID, true)...) + filters = append(filters, instanceMemberFilters(orgID, "USER-LOGIN-CLIENT")...) + } return append(filters, projectFilters()..., ) } -func orgEvents(ctx context.Context, instanceID, orgID, name, projectID, defaultDomain string, externalSecure bool, machine, human bool) []eventstore.Command { +func orgEvents(ctx context.Context, instanceID, orgID, name, projectID, defaultDomain string, externalSecure bool, machine, human, loginClient bool) []eventstore.Command { instanceAgg := instance.NewAggregate(instanceID) orgAgg := org.NewAggregate(orgID) domain := strings.ToLower(name + "." + defaultDomain) @@ -173,13 +177,17 @@ func orgEvents(ctx context.Context, instanceID, orgID, name, projectID, defaultD events = append(events, humanEvents(ctx, instanceID, orgID, userID)...) owner = userID } + if loginClient { + userID := "USER-LOGIN-CLIENT" + events = append(events, loginClientEvents(ctx, instanceID, orgID, userID, "LOGIN-CLIENT-PAT")...) + } events = append(events, projectAddedEvents(ctx, instanceID, orgID, projectID, owner, externalSecure)...) return events } func orgIDs() []string { - return slices.Concat([]string{"USER-MACHINE", "PAT", "USER"}, projectClientIDs()) + return slices.Concat([]string{"USER-MACHINE", "PAT", "USER", "USER-LOGIN-CLIENT", "LOGIN-CLIENT-PAT"}, projectClientIDs()) } func instancePoliciesFilters(instanceID string) []expect { @@ -363,7 +371,7 @@ func instanceElementsConfig() *SecretGenerators { func setupInstanceFilters(instanceID, orgID, projectID, appID, domain string) []expect { return slices.Concat( setupInstanceElementsFilters(instanceID), - orgFilters(orgID, true, true), + orgFilters(orgID, true, true, true), generatedDomainFilters(instanceID, orgID, projectID, appID, domain), ) } @@ -371,7 +379,7 @@ func setupInstanceFilters(instanceID, orgID, projectID, appID, domain string) [] func setupInstanceEvents(ctx context.Context, instanceID, orgID, projectID, appID, instanceName, orgName string, defaultLanguage language.Tag, domain string, externalSecure bool) []eventstore.Command { return slices.Concat( setupInstanceElementsEvents(ctx, instanceID, instanceName, defaultLanguage), - orgEvents(ctx, instanceID, orgID, orgName, projectID, domain, externalSecure, true, true), + orgEvents(ctx, instanceID, orgID, orgName, projectID, domain, externalSecure, true, true, true), generatedDomainEvents(ctx, instanceID, orgID, projectID, appID, domain), instanceCreatedMilestoneEvent(ctx, instanceID), ) @@ -380,9 +388,10 @@ func setupInstanceEvents(ctx context.Context, instanceID, orgID, projectID, appI func setupInstanceConfig() *InstanceSetup { conf := setupInstanceElementsConfig() conf.Org = InstanceOrgSetup{ - Name: "ZITADEL", - Machine: instanceSetupMachineConfig(), - Human: instanceSetupHumanConfig(), + Name: "ZITADEL", + Machine: instanceSetupMachineConfig(), + Human: instanceSetupHumanConfig(), + LoginClient: instanceSetupLoginClientConfig(), } conf.CustomDomain = "" return conf @@ -541,6 +550,43 @@ func instanceSetupMachineConfig() *AddMachine { } } +func loginClientFilters(orgID string, pat bool) []expect { + filters := []expect{ + expectFilter(), + expectFilter( + org.NewDomainPolicyAddedEvent( + context.Background(), + &org.NewAggregate(orgID).Aggregate, + true, + true, + true, + ), + ), + } + if pat { + filters = append(filters, + expectFilter(), + expectFilter(), + ) + } + return filters +} + +func instanceSetupLoginClientConfig() *AddLoginClient { + return &AddLoginClient{ + Machine: &Machine{ + Username: "zitadel-login-client", + Name: "ZITADEL-login-client", + Description: "Login Client", + AccessTokenType: domain.OIDCTokenTypeBearer, + }, + Pat: &AddPat{ + ExpirationDate: time.Time{}, + Scopes: nil, + }, + } +} + func projectFilters() []expect { return []expect{ expectFilter(), @@ -551,11 +597,23 @@ func projectFilters() []expect { } func adminMemberFilters(orgID, userID string) []expect { + filters := append( + orgMemberFilters(orgID, userID), + instanceMemberFilters(orgID, userID)..., + ) + return filters +} +func orgMemberFilters(orgID, userID string) []expect { return []expect{ expectFilter( addHumanEvent(context.Background(), orgID, userID), ), expectFilter(), + } +} + +func instanceMemberFilters(orgID, userID string) []expect { + return []expect{ expectFilter( addHumanEvent(context.Background(), orgID, userID), ), @@ -631,6 +689,40 @@ func addMachineEvent(ctx context.Context, orgID, userID string) *user.MachineAdd ) } +// loginClientEvents all events from setup to create the login client user +func loginClientEvents(ctx context.Context, instanceID, orgID, userID, patID string) []eventstore.Command { + agg := user.NewAggregate(userID, orgID) + instanceAgg := instance.NewAggregate(instanceID) + events := []eventstore.Command{ + addLoginClientEvent(ctx, orgID, userID), + instance.NewMemberAddedEvent(ctx, &instanceAgg.Aggregate, userID, domain.RoleIAMLoginClient), + } + if patID != "" { + events = append(events, + user.NewPersonalAccessTokenAddedEvent( + ctx, + &agg.Aggregate, + patID, + time.Date(9999, time.December, 31, 23, 59, 59, 0, time.UTC), + nil, + ), + ) + } + return events +} + +func addLoginClientEvent(ctx context.Context, orgID, userID string) *user.MachineAddedEvent { + agg := user.NewAggregate(userID, orgID) + return user.NewMachineAddedEvent(ctx, + &agg.Aggregate, + "zitadel-login-client", + "ZITADEL-login-client", + "Login Client", + false, + domain.OIDCTokenTypeBearer, + ) +} + func testSetup(ctx context.Context, c *Commands, validations []preparation.Validation) error { //nolint:staticcheck cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, validations...) @@ -715,6 +807,13 @@ func TestCommandSide_setupMinimalInterfaces(t *testing.T) { }) } } +func validZitadelRoles() []authz.RoleMapping { + return []authz.RoleMapping{ + {Role: domain.RoleOrgOwner, Permissions: []string{""}}, + {Role: domain.RoleIAMOwner, Permissions: []string{""}}, + {Role: domain.RoleIAMLoginClient, Permissions: []string{""}}, + } +} func TestCommandSide_setupAdmins(t *testing.T) { type fields struct { @@ -730,12 +829,14 @@ func TestCommandSide_setupAdmins(t *testing.T) { orgAgg *org.Aggregate machine *AddMachine human *AddHuman + loginClient *AddLoginClient } type res struct { - owner string - pat bool - machineKey bool - err func(error) bool + owner string + pat bool + machineKey bool + loginClientPat bool + err func(error) bool } tests := []struct { name string @@ -763,10 +864,7 @@ func TestCommandSide_setupAdmins(t *testing.T) { ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "USER"), userPasswordHasher: mockPasswordHasher("x"), - roles: []authz.RoleMapping{ - {Role: domain.RoleOrgOwner, Permissions: []string{""}}, - {Role: domain.RoleIAMOwner, Permissions: []string{""}}, - }, + roles: validZitadelRoles(), }, args: args{ ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), @@ -800,11 +898,8 @@ func TestCommandSide_setupAdmins(t *testing.T) { }, )..., ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "USER-MACHINE", "PAT"), - roles: []authz.RoleMapping{ - {Role: domain.RoleOrgOwner, Permissions: []string{""}}, - {Role: domain.RoleIAMOwner, Permissions: []string{""}}, - }, + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "USER-MACHINE", "PAT"), + roles: validZitadelRoles(), keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ @@ -850,11 +945,8 @@ func TestCommandSide_setupAdmins(t *testing.T) { ), userPasswordHasher: mockPasswordHasher("x"), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "USER-MACHINE", "PAT", "USER"), - roles: []authz.RoleMapping{ - {Role: domain.RoleOrgOwner, Permissions: []string{""}}, - {Role: domain.RoleIAMOwner, Permissions: []string{""}}, - }, - keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + roles: validZitadelRoles(), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), @@ -870,6 +962,63 @@ func TestCommandSide_setupAdmins(t *testing.T) { err: nil, }, }, + { + name: "human, machine and login client, ok", + fields: fields{ + eventstore: expectEventstore( + slices.Concat( + machineFilters("ORG", true), + adminMemberFilters("ORG", "USER-MACHINE"), + humanFilters("ORG"), + adminMemberFilters("ORG", "USER"), + loginClientFilters("ORG", true), + instanceMemberFilters("ORG", "USER-LOGIN-CLIENT"), + []expect{ + expectPush( + slices.Concat( + machineEvents(context.Background(), + "INSTANCE", + "ORG", + "USER-MACHINE", + "PAT", + ), + humanEvents(context.Background(), + "INSTANCE", + "ORG", + "USER", + ), + loginClientEvents(context.Background(), + "INSTANCE", + "ORG", + "USER-LOGIN-CLIENT", + "LOGIN-CLIENT-PAT", + ), + )..., + ), + }, + )..., + ), + userPasswordHasher: mockPasswordHasher("x"), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "USER-MACHINE", "PAT", "USER", "USER-LOGIN-CLIENT", "LOGIN-CLIENT-PAT"), + roles: validZitadelRoles(), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), + instanceAgg: instance.NewAggregate("INSTANCE"), + orgAgg: org.NewAggregate("ORG"), + machine: instanceSetupMachineConfig(), + human: instanceSetupHumanConfig(), + loginClient: instanceSetupLoginClientConfig(), + }, + res: res{ + owner: "USER", + pat: true, + machineKey: false, + loginClientPat: true, + err: nil, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -881,7 +1030,7 @@ func TestCommandSide_setupAdmins(t *testing.T) { keyAlgorithm: tt.fields.keyAlgorithm, } validations := make([]preparation.Validation, 0) - owner, pat, mk, err := setupAdmins(r, &validations, tt.args.instanceAgg, tt.args.orgAgg, tt.args.machine, tt.args.human) + owner, pat, mk, loginClientPat, err := setupAdmins(r, &validations, tt.args.instanceAgg, tt.args.orgAgg, tt.args.machine, tt.args.human, tt.args.loginClient) if tt.res.err == nil { assert.NoError(t, err) } @@ -905,6 +1054,9 @@ func TestCommandSide_setupAdmins(t *testing.T) { if tt.res.machineKey { assert.NotNil(t, mk) } + if tt.res.loginClientPat { + assert.NotNil(t, loginClientPat) + } } }) } @@ -924,12 +1076,14 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { orgName string machine *AddMachine human *AddHuman + loginClient *AddLoginClient ids ZitadelConfig } type res struct { - pat bool - machineKey bool - err func(error) bool + pat bool + machineKey bool + loginClientPat bool + err func(error) bool } tests := []struct { name string @@ -938,7 +1092,7 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { res res }{ { - name: "human and machine, ok", + name: "human, machine and login client, ok", fields: fields{ eventstore: expectEventstore( slices.Concat( @@ -946,6 +1100,7 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { "ORG", true, true, + true, ), []expect{ expectPush( @@ -959,6 +1114,7 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { false, true, true, + true, ), )..., ), @@ -967,11 +1123,8 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { ), userPasswordHasher: mockPasswordHasher("x"), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, orgIDs()...), - roles: []authz.RoleMapping{ - {Role: domain.RoleOrgOwner, Permissions: []string{""}}, - {Role: domain.RoleIAMOwner, Permissions: []string{""}}, - }, - keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + roles: validZitadelRoles(), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), @@ -1007,6 +1160,18 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { Password: "password", PasswordChangeRequired: false, }, + loginClient: &AddLoginClient{ + Machine: &Machine{ + Username: "zitadel-login-client", + Name: "ZITADEL-login-client", + Description: "Login Client", + AccessTokenType: domain.OIDCTokenTypeBearer, + }, + Pat: &AddPat{ + ExpirationDate: time.Time{}, + Scopes: nil, + }, + }, ids: ZitadelConfig{ instanceID: "INSTANCE", orgID: "ORG", @@ -1018,9 +1183,10 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { }, }, res: res{ - pat: true, - machineKey: false, - err: nil, + pat: true, + machineKey: false, + loginClientPat: true, + err: nil, }, }, } @@ -1034,7 +1200,7 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { keyAlgorithm: tt.fields.keyAlgorithm, } validations := make([]preparation.Validation, 0) - pat, mk, err := setupDefaultOrg(tt.args.ctx, r, &validations, tt.args.instanceAgg, tt.args.orgName, tt.args.machine, tt.args.human, tt.args.ids) + pat, mk, loginClientPat, err := setupDefaultOrg(tt.args.ctx, r, &validations, tt.args.instanceAgg, tt.args.orgName, tt.args.machine, tt.args.human, tt.args.loginClient, tt.args.ids) if tt.res.err == nil { assert.NoError(t, err) } @@ -1057,6 +1223,9 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { if tt.res.machineKey { assert.NotNil(t, mk) } + if tt.res.loginClientPat { + assert.NotNil(t, loginClientPat) + } } }) } @@ -1140,9 +1309,10 @@ func TestCommandSide_setUpInstance(t *testing.T) { setup *InstanceSetup } type res struct { - pat bool - machineKey bool - err func(error) bool + pat bool + machineKey bool + loginClientPat bool + err func(error) bool } tests := []struct { name string @@ -1175,11 +1345,8 @@ func TestCommandSide_setUpInstance(t *testing.T) { ), userPasswordHasher: mockPasswordHasher("x"), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, orgIDs()...), - roles: []authz.RoleMapping{ - {Role: domain.RoleOrgOwner, Permissions: []string{""}}, - {Role: domain.RoleIAMOwner, Permissions: []string{""}}, - }, - keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + roles: validZitadelRoles(), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), generateDomain: func(string, string) (string, error) { return "DOMAIN", nil }, @@ -1204,7 +1371,7 @@ func TestCommandSide_setUpInstance(t *testing.T) { GenerateDomain: tt.fields.generateDomain, } - validations, pat, mk, err := setUpInstance(tt.args.ctx, r, tt.args.setup) + validations, pat, mk, loginClientPat, err := setUpInstance(tt.args.ctx, r, tt.args.setup) if tt.res.err == nil { assert.NoError(t, err) } @@ -1227,6 +1394,9 @@ func TestCommandSide_setUpInstance(t *testing.T) { if tt.res.machineKey { assert.NotNil(t, mk) } + if tt.res.loginClientPat { + assert.NotNil(t, loginClientPat) + } } }) } diff --git a/internal/command/key_pair.go b/internal/command/key_pair.go index 90eaf7e3da..76193431d6 100644 --- a/internal/command/key_pair.go +++ b/internal/command/key_pair.go @@ -13,31 +13,6 @@ import ( "github.com/zitadel/zitadel/internal/repository/keypair" ) -func (c *Commands) GenerateSigningKeyPair(ctx context.Context, algorithm string) error { - privateCrypto, publicCrypto, err := crypto.GenerateEncryptedKeyPair(c.keySize, c.keyAlgorithm) - if err != nil { - return err - } - keyID, err := c.idGenerator.Next() - if err != nil { - return err - } - - privateKeyExp := time.Now().UTC().Add(c.privateKeyLifetime) - publicKeyExp := time.Now().UTC().Add(c.publicKeyLifetime) - - keyPairWriteModel := NewKeyPairWriteModel(keyID, authz.GetInstance(ctx).InstanceID()) - keyAgg := KeyPairAggregateFromWriteModel(&keyPairWriteModel.WriteModel) - _, err = c.eventstore.Push(ctx, keypair.NewAddedEvent( - ctx, - keyAgg, - crypto.KeyUsageSigning, - algorithm, - privateCrypto, publicCrypto, - privateKeyExp, publicKeyExp)) - return err -} - func (c *Commands) GenerateSAMLCACertificate(ctx context.Context, algorithm string) error { now := time.Now().UTC() after := now.Add(c.certificateLifetime) diff --git a/internal/command/org.go b/internal/command/org.go index faab882d68..ff0208e5e2 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -24,9 +24,15 @@ type InstanceOrgSetup struct { CustomDomain string Human *AddHuman Machine *AddMachine + LoginClient *AddLoginClient Roles []string } +type AddLoginClient struct { + Machine *Machine + Pat *AddPat +} + type OrgSetup struct { Name string CustomDomain string @@ -117,7 +123,7 @@ func (c *Commands) newOrgSetupCommands(ctx context.Context, orgID string, orgSet func (c *orgSetupCommands) setupOrgAdmin(admin *OrgSetupAdmin, allowInitialMail bool) error { if admin.ID != "" { - c.validations = append(c.validations, c.commands.AddOrgMemberCommand(c.aggregate, admin.ID, orgAdminRoles(admin.Roles)...)) + c.validations = append(c.validations, c.commands.AddOrgMemberCommand(&AddOrgMember{OrgID: c.aggregate.ID, UserID: admin.ID, Roles: orgAdminRoles(admin.Roles)})) return nil } @@ -141,7 +147,7 @@ func (c *orgSetupCommands) setupOrgAdmin(admin *OrgSetupAdmin, allowInitialMail return err } } - c.validations = append(c.validations, c.commands.AddOrgMemberCommand(c.aggregate, userID, orgAdminRoles(admin.Roles)...)) + c.validations = append(c.validations, c.commands.AddOrgMemberCommand(&AddOrgMember{OrgID: c.aggregate.ID, UserID: userID, Roles: orgAdminRoles(admin.Roles)})) return nil } @@ -353,16 +359,15 @@ func (c *Commands) addOrgWithIDAndMember(ctx context.Context, name, userID, reso if err != nil { return nil, err } - err = c.checkUserExists(ctx, userID, resourceOwner) + _, err = c.checkUserExists(ctx, userID, resourceOwner) if err != nil { return nil, err } - addedMember := NewOrgMemberWriteModel(addedOrg.AggregateID, userID) - orgMemberEvent, err := c.addOrgMember(ctx, orgAgg, addedMember, domain.NewMember(orgAgg.ID, userID, domain.RoleOrgOwner)) - if err != nil { + addMember := &AddOrgMember{OrgID: orgAgg.ID, UserID: userID, Roles: []string{domain.RoleOrgOwner}} + if err := addMember.IsValid(c.zitadelRoles); err != nil { return nil, err } - events = append(events, orgMemberEvent) + events = append(events, org.NewMemberAddedEvent(ctx, orgAgg, addMember.UserID, addMember.Roles...)) if setOrgInactive { deactivateOrgEvent := org.NewOrgDeactivatedEvent(ctx, orgAgg) events = append(events, deactivateOrgEvent) diff --git a/internal/command/org_member.go b/internal/command/org_member.go index bf1ae91d8a..a384145e50 100644 --- a/internal/command/org_member.go +++ b/internal/command/org_member.go @@ -2,8 +2,9 @@ package command import ( "context" - "reflect" + "slices" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -12,29 +13,22 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddOrgMemberCommand(a *org.Aggregate, userID string, roles ...string) preparation.Validation { +func (c *Commands) AddOrgMemberCommand(member *AddOrgMember) preparation.Validation { return func() (preparation.CreateCommands, error) { - if userID == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "ORG-4Mlfs", "Errors.Invalid.Argument") - } - if len(roles) == 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "V2-PfYhb", "Errors.Invalid.Argument") - } - - if len(domain.CheckForInvalidRoles(roles, domain.OrgRolePrefix, c.zitadelRoles)) > 0 && len(domain.CheckForInvalidRoles(roles, domain.RoleSelfManagementGlobal, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "Org-4N8es", "Errors.Org.MemberInvalid") + if err := member.IsValid(c.zitadelRoles); err != nil { + return nil, err } return func(ctx context.Context, filter preparation.FilterToQueryReducer) (_ []eventstore.Command, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if exists, err := ExistsUser(ctx, filter, userID, "", false); err != nil || !exists { + if exists, err := ExistsUser(ctx, filter, member.UserID, "", false); err != nil || !exists { return nil, zerrors.ThrowPreconditionFailed(err, "ORG-GoXOn", "Errors.User.NotFound") } - if isMember, err := IsOrgMember(ctx, filter, a.ID, userID); err != nil || isMember { + if isMember, err := IsOrgMember(ctx, filter, member.OrgID, member.UserID); err != nil || isMember { return nil, zerrors.ThrowAlreadyExists(err, "ORG-poWwe", "Errors.Org.Member.AlreadyExists") } - return []eventstore.Command{org.NewMemberAddedEvent(ctx, &a.Aggregate, userID, roles...)}, nil + return []eventstore.Command{org.NewMemberAddedEvent(ctx, &org.NewAggregate(member.OrgID).Aggregate, member.UserID, member.Roles...)}, nil }, nil } @@ -76,12 +70,33 @@ func IsOrgMember(ctx context.Context, filter preparation.FilterToQueryReducer, o return isMember, nil } -func (c *Commands) AddOrgMember(ctx context.Context, orgID, userID string, roles ...string) (_ *domain.Member, err error) { +type AddOrgMember struct { + OrgID string + UserID string + Roles []string +} + +func (m *AddOrgMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if m.UserID == "" || m.OrgID == "" || len(m.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "ORG-4Mlfs", "Errors.Invalid.Argument") + } + if len(domain.CheckForInvalidRoles(m.Roles, domain.OrgRolePrefix, zitadelRoles)) > 0 && len(domain.CheckForInvalidRoles(m.Roles, domain.RoleSelfManagementGlobal, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "Org-4N8es", "Errors.Org.MemberInvalid") + } + return nil +} + +func (c *Commands) AddOrgMember(ctx context.Context, member *AddOrgMember) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - - orgAgg := org.NewAggregate(orgID) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.AddOrgMemberCommand(orgAgg, userID, roles...)) + if err := c.checkOrgExists(ctx, member.OrgID); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateOrgMember(ctx, member.OrgID, member.OrgID); err != nil { + return nil, err + } + //nolint:staticcheck + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.AddOrgMemberCommand(member)) if err != nil { return nil, err } @@ -89,51 +104,59 @@ func (c *Commands) AddOrgMember(ctx context.Context, orgID, userID string, roles if err != nil { return nil, err } - addedMember := NewOrgMemberWriteModel(orgID, userID) + addedMember := NewOrgMemberWriteModel(member.OrgID, member.UserID) err = AppendAndReduce(addedMember, events...) if err != nil { return nil, err } - return memberWriteModelToMember(&addedMember.MemberWriteModel), nil + return writeModelToObjectDetails(&addedMember.WriteModel), nil } -func (c *Commands) addOrgMember(ctx context.Context, orgAgg *eventstore.Aggregate, addedMember *OrgMemberWriteModel, member *domain.Member) (eventstore.Command, error) { - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "Org-W8m4l", "Errors.Org.MemberInvalid") +type ChangeOrgMember struct { + OrgID string + UserID string + Roles []string +} + +func (c *ChangeOrgMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if c.OrgID == "" || c.UserID == "" || len(c.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "Org-LiaZi", "Errors.Org.MemberInvalid") } - if len(domain.CheckForInvalidRoles(member.Roles, domain.OrgRolePrefix, c.zitadelRoles)) > 0 && len(domain.CheckForInvalidRoles(member.Roles, domain.RoleSelfManagementGlobal, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "Org-4N8es", "Errors.Org.MemberInvalid") - } - err := c.eventstore.FilterToQueryReducer(ctx, addedMember) - if err != nil { - return nil, err - } - if addedMember.State == domain.MemberStateActive { - return nil, zerrors.ThrowAlreadyExists(nil, "Org-PtXi1", "Errors.Org.Member.AlreadyExists") + if len(domain.CheckForInvalidRoles(c.Roles, domain.OrgRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "IAM-m9fG8", "Errors.Org.MemberInvalid") } - return org.NewMemberAddedEvent(ctx, orgAgg, member.UserID, member.Roles...), nil + return nil } // ChangeOrgMember updates an existing member -func (c *Commands) ChangeOrgMember(ctx context.Context, member *domain.Member) (*domain.Member, error) { - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "Org-LiaZi", "Errors.Org.MemberInvalid") - } - if len(domain.CheckForInvalidRoles(member.Roles, domain.OrgRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "IAM-m9fG8", "Errors.Org.MemberInvalid") - } - - existingMember, err := c.orgMemberWriteModelByID(ctx, member.AggregateID, member.UserID) - if err != nil { +func (c *Commands) ChangeOrgMember(ctx context.Context, member *ChangeOrgMember) (*domain.ObjectDetails, error) { + if err := member.IsValid(c.zitadelRoles); err != nil { return nil, err } - if reflect.DeepEqual(existingMember.Roles, member.Roles) { - return nil, zerrors.ThrowPreconditionFailed(nil, "Org-LiaZi", "Errors.Org.Member.RolesNotChanged") + existingMember, err := c.orgMemberWriteModelByID(ctx, member.OrgID, member.UserID) + if err != nil { + return nil, err } - orgAgg := OrgAggregateFromWriteModel(&existingMember.MemberWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, org.NewMemberChangedEvent(ctx, orgAgg, member.UserID, member.Roles...)) + if !existingMember.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "Org-D8JxR", "Errors.NotFound") + } + if err := c.checkPermissionUpdateOrgMember(ctx, existingMember.ResourceOwner, existingMember.AggregateID); err != nil { + return nil, err + } + + if slices.Compare(existingMember.Roles, member.Roles) == 0 { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + + pushedEvents, err := c.eventstore.Push(ctx, + org.NewMemberChangedEvent(ctx, + OrgAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel), + member.UserID, + member.Roles..., + ), + ) if err != nil { return nil, err } @@ -142,30 +165,39 @@ func (c *Commands) ChangeOrgMember(ctx context.Context, member *domain.Member) ( return nil, err } - return memberWriteModelToMember(&existingMember.MemberWriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) RemoveOrgMember(ctx context.Context, orgID, userID string) (*domain.ObjectDetails, error) { - m, err := c.orgMemberWriteModelByID(ctx, orgID, userID) - if err != nil && !zerrors.IsNotFound(err) { + if orgID == "" || userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "Org-LiaZi", "Errors.Org.MemberInvalid") + } + existingMember, err := c.orgMemberWriteModelByID(ctx, orgID, userID) + if err != nil { return nil, err } - if zerrors.IsNotFound(err) { - // empty response because we have no data that match the request - return &domain.ObjectDetails{}, nil + if !existingMember.State.Exists() { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + if err := c.checkPermissionDeleteOrgMember(ctx, existingMember.ResourceOwner, existingMember.AggregateID); err != nil { + return nil, err } - orgAgg := OrgAggregateFromWriteModel(&m.MemberWriteModel.WriteModel) - removeEvent := c.removeOrgMember(ctx, orgAgg, userID, false) - pushedEvents, err := c.eventstore.Push(ctx, removeEvent) + pushedEvents, err := c.eventstore.Push(ctx, + c.removeOrgMember(ctx, + OrgAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel), + userID, + false, + ), + ) if err != nil { return nil, err } - err = AppendAndReduce(m, pushedEvents...) + err = AppendAndReduce(existingMember, pushedEvents...) if err != nil { return nil, err } - return writeModelToObjectDetails(&m.WriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) removeOrgMember(ctx context.Context, orgAgg *eventstore.Aggregate, userID string, cascade bool) eventstore.Command { @@ -189,9 +221,5 @@ func (c *Commands) orgMemberWriteModelByID(ctx context.Context, orgID, userID st return nil, err } - if writeModel.State == domain.MemberStateUnspecified || writeModel.State == domain.MemberStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "Org-D8JxR", "Errors.NotFound") - } - return writeModel, nil } diff --git a/internal/command/org_member_test.go b/internal/command/org_member_test.go index 4e2e926b52..25dd3b818a 100644 --- a/internal/command/org_member_test.go +++ b/internal/command/org_member_test.go @@ -11,24 +11,19 @@ import ( "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/org" - "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) func TestAddMember(t *testing.T) { type args struct { - a *org.Aggregate - userID string - roles []string + member *AddOrgMember zitadelRoles []authz.RoleMapping filter preparation.FilterToQueryReducer } ctx := context.Background() - agg := org.NewAggregate("test") tests := []struct { name string @@ -38,8 +33,10 @@ func TestAddMember(t *testing.T) { { name: "no user id", args: args{ - a: agg, - userID: "", + member: &AddOrgMember{ + OrgID: "test", + UserID: "", + }, }, want: Want{ ValidationErr: zerrors.ThrowInvalidArgument(nil, "ORG-4Mlfs", "Errors.Invalid.Argument"), @@ -48,19 +45,23 @@ func TestAddMember(t *testing.T) { { name: "no roles", args: args{ - a: agg, - userID: "12342", + member: &AddOrgMember{ + OrgID: "test", + UserID: "12342", + }, }, want: Want{ - ValidationErr: zerrors.ThrowInvalidArgument(nil, "V2-PfYhb", "Errors.Invalid.Argument"), + ValidationErr: zerrors.ThrowInvalidArgument(nil, "ORG-4Mlfs", "Errors.Invalid.Argument"), }, }, { name: "TODO: invalid roles", args: args{ - a: agg, - userID: "123", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "test", + UserID: "12342", + Roles: []string{"ORG_OWNER"}, + }, }, want: Want{ ValidationErr: zerrors.ThrowInvalidArgument(nil, "Org-4N8es", ""), @@ -69,9 +70,11 @@ func TestAddMember(t *testing.T) { { name: "user not exists", args: args{ - a: agg, - userID: "userID", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "test", + UserID: "userID", + Roles: []string{"ORG_OWNER"}, + }, zitadelRoles: []authz.RoleMapping{ { Role: "ORG_OWNER", @@ -89,9 +92,11 @@ func TestAddMember(t *testing.T) { { name: "already member", args: args{ - a: agg, - userID: "userID", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "test", + UserID: "userID", + Roles: []string{"ORG_OWNER"}, + }, zitadelRoles: []authz.RoleMapping{ { Role: "ORG_OWNER", @@ -129,9 +134,11 @@ func TestAddMember(t *testing.T) { { name: "correct", args: args{ - a: agg, - userID: "userID", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "test", + UserID: "userID", + Roles: []string{"ORG_OWNER"}, + }, zitadelRoles: []authz.RoleMapping{ { Role: "ORG_OWNER", @@ -158,14 +165,14 @@ func TestAddMember(t *testing.T) { }, want: Want{ Commands: []eventstore.Command{ - org.NewMemberAddedEvent(ctx, &agg.Aggregate, "userID", "ORG_OWNER"), + org.NewMemberAddedEvent(ctx, &org.NewAggregate("test").Aggregate, "userID", "ORG_OWNER"), }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - AssertValidation(t, context.Background(), (&Commands{zitadelRoles: tt.args.zitadelRoles}).AddOrgMemberCommand(tt.args.a, tt.args.userID, tt.args.roles...), tt.args.filter, tt.want) + AssertValidation(t, context.Background(), (&Commands{zitadelRoles: tt.args.zitadelRoles}).AddOrgMemberCommand(tt.args.member), tt.args.filter, tt.want) }) } } @@ -287,17 +294,15 @@ func TestIsMember(t *testing.T) { func TestCommandSide_AddOrgMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping } type args struct { - ctx context.Context - userID string - orgID string - roles []string + member *AddOrgMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -309,13 +314,22 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - orgID: "org1", + member: &AddOrgMember{ + OrgID: "org1", + }, }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -324,15 +338,24 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - orgID: "org1", - userID: "user1", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -341,10 +364,18 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -352,10 +383,11 @@ func TestCommandSide_AddOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - orgID: "org1", - userID: "user1", - roles: []string{domain.RoleOrgOwner}, + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, }, res: res{ err: zerrors.IsPreconditionFailed, @@ -364,8 +396,15 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "member already exists, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -391,6 +430,7 @@ func TestCommandSide_AddOrgMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -398,10 +438,11 @@ func TestCommandSide_AddOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - orgID: "org1", - userID: "user1", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -410,8 +451,15 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "member add uniqueconstraint err, already exists", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -437,6 +485,7 @@ func TestCommandSide_AddOrgMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -444,10 +493,11 @@ func TestCommandSide_AddOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - orgID: "org1", - userID: "user1", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -456,8 +506,15 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "member add, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -483,6 +540,7 @@ func TestCommandSide_AddOrgMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -490,30 +548,58 @@ func TestCommandSide_AddOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - orgID: "org1", - userID: "user1", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, }, res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "org1", - }, - UserID: "user1", - Roles: []string{domain.RoleOrgOwner}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, + { + name: "member add, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: domain.RoleOrgOwner, + }, + }, + }, + args: args{ + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.AddOrgMember(tt.args.ctx, tt.args.orgID, tt.args.userID, tt.args.roles...) + got, err := r.AddOrgMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -521,7 +607,7 @@ func TestCommandSide_AddOrgMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -529,15 +615,15 @@ func TestCommandSide_AddOrgMember(t *testing.T) { func TestCommandSide_ChangeOrgMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping } type args struct { - ctx context.Context - member *domain.Member + member *ChangeOrgMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -549,16 +635,12 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, + member: &ChangeOrgMember{ + OrgID: "org1", }, }, res: res{ @@ -568,16 +650,12 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, + member: &ChangeOrgMember{ + OrgID: "org1", UserID: "user1", Roles: []string{"PROJECT_OWNER"}, }, @@ -589,10 +667,10 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { { name: "member not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -600,11 +678,8 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, + member: &ChangeOrgMember{ + OrgID: "org1", UserID: "user1", Roles: []string{"ORG_OWNER"}, }, @@ -614,10 +689,9 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { }, }, { - name: "member not changed, precondition error", + name: "member not changed, no change", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewMemberAddedEvent(context.Background(), @@ -628,6 +702,7 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -635,24 +710,22 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, + member: &ChangeOrgMember{ + OrgID: "org1", UserID: "user1", Roles: []string{"ORG_OWNER"}, }, }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "member change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewMemberAddedEvent(context.Background(), @@ -670,6 +743,7 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "ORG_OWNER", @@ -680,160 +754,210 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, + member: &ChangeOrgMember{ + OrgID: "org1", UserID: "user1", Roles: []string{"ORG_OWNER", "ORG_OWNER_VIEWER"}, }, }, - res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "org1", - }, - UserID: "user1", - Roles: []string{"ORG_OWNER", "ORG_OWNER_VIEWER"}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, - } - got, err := r.ChangeOrgMember(tt.args.ctx, tt.args.member) - if tt.res.err == nil { - assert.NoError(t, err) - } - if tt.res.err != nil && !tt.res.err(err) { - t.Errorf("got wrong err: %v ", err) - } - if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) - } - }) - } -} - -func TestCommandSide_RemoveOrgMember(t *testing.T) { - type fields struct { - eventstore *eventstore.Eventstore - } - type args struct { - ctx context.Context - projectID string - userID string - resourceOwner string - } - type res struct { - want *domain.ObjectDetails - err func(error) bool - } - tests := []struct { - name string - fields fields - args args - res res - }{ - { - name: "invalid member projectid missing, error", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, - args: args{ - ctx: context.Background(), - projectID: "", - userID: "user1", - resourceOwner: "org1", - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, - { - name: "invalid member userid missing, error", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, - args: args{ - ctx: context.Background(), - projectID: "project1", - userID: "", - resourceOwner: "org1", - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, - { - name: "member not existing, empty object details result", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), - }, - args: args{ - ctx: context.Background(), - projectID: "project1", - userID: "user1", - resourceOwner: "org1", - }, - res: res{ - want: &domain.ObjectDetails{}, - }, - }, - { - name: "member remove, ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - project.NewProjectMemberAddedEvent(context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "user1", - []string{"PROJECT_OWNER"}..., - ), - ), - ), - expectPush( - project.NewProjectMemberRemovedEvent(context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "user1", - ), - ), - ), - }, - args: args{ - ctx: context.Background(), - projectID: "project1", - userID: "user1", - resourceOwner: "org1", - }, res: res{ want: &domain.ObjectDetails{ ResourceOwner: "org1", }, }, }, + { + name: "member change, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewMemberAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "user1", + []string{"ORG_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: "ORG_OWNER", + }, + { + Role: "ORG_OWNER_VIEWER", + }, + }, + }, + args: args{ + member: &ChangeOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER", "ORG_OWNER_VIEWER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.RemoveProjectMember(tt.args.ctx, tt.args.projectID, tt.args.userID, tt.args.resourceOwner) + got, err := r.ChangeOrgMember(context.Background(), tt.args.member) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_RemoveOrgMember(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + orgID string + userID string + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "invalid member orgID missing, error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "", + userID: "user1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "invalid member userid missing, error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "member not existing, empty object details result", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "member remove, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewMemberAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "user1", + []string{"PROJECT_OWNER"}..., + ), + ), + ), + expectPush( + org.NewMemberRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "user1", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "member remove, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewMemberAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "user1", + []string{"PROJECT_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.RemoveOrgMember(tt.args.ctx, tt.args.orgID, tt.args.userID) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/org_model.go b/internal/command/org_model.go index 2661af9bd9..9b39805609 100644 --- a/internal/command/org_model.go +++ b/internal/command/org_model.go @@ -1,6 +1,8 @@ package command import ( + "context" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/org" @@ -63,3 +65,7 @@ func (wm *OrgWriteModel) Query() *eventstore.SearchQueryBuilder { func OrgAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { return eventstore.AggregateFromWriteModel(wm, org.AggregateType, org.AggregateVersion) } + +func OrgAggregateFromWriteModelWithCTX(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return org.AggregateFromWriteModel(ctx, wm) +} diff --git a/internal/command/org_test.go b/internal/command/org_test.go index 4239be760a..a5d00d9bfd 100644 --- a/internal/command/org_test.go +++ b/internal/command/org_test.go @@ -73,7 +73,7 @@ func TestAddOrg(t *testing.T) { func TestCommandSide_AddOrg(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator zitadelRoles []authz.RoleMapping } @@ -97,9 +97,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "invalid org, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -113,9 +111,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "invalid org (spaces), error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -130,8 +126,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "user removed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilterOrgDomainNotFound(), expectFilter( eventFromEventPusher( @@ -174,8 +169,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "push failed unique constraint, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilterOrgDomainNotFound(), expectFilter( eventFromEventPusher( @@ -193,7 +187,6 @@ func TestCommandSide_AddOrg(t *testing.T) { ), ), ), - expectFilterOrgMemberNotFound(), expectPushFailed(zerrors.ThrowAlreadyExists(nil, "id", "internal"), org.NewOrgAddedEvent( context.Background(), @@ -242,8 +235,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "push failed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilterOrgDomainNotFound(), expectFilter( eventFromEventPusher( @@ -261,7 +253,6 @@ func TestCommandSide_AddOrg(t *testing.T) { ), ), ), - expectFilterOrgMemberNotFound(), expectPushFailed(zerrors.ThrowInternal(nil, "id", "internal"), org.NewOrgAddedEvent( context.Background(), @@ -310,8 +301,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "add org, no error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilterOrgDomainNotFound(), expectFilter( eventFromEventPusher( @@ -329,7 +319,6 @@ func TestCommandSide_AddOrg(t *testing.T) { ), ), ), - expectFilterOrgMemberNotFound(), expectPush( org.NewOrgAddedEvent(context.Background(), &org.NewAggregate("org2").Aggregate, @@ -381,8 +370,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "add org (remove spaces), no error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilterOrgDomainNotFound(), expectFilter( eventFromEventPusher( @@ -400,7 +388,6 @@ func TestCommandSide_AddOrg(t *testing.T) { ), ), ), - expectFilterOrgMemberNotFound(), expectPush( org.NewOrgAddedEvent(context.Background(), &org.NewAggregate("org2").Aggregate, @@ -453,7 +440,7 @@ func TestCommandSide_AddOrg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, zitadelRoles: tt.fields.zitadelRoles, } @@ -473,7 +460,7 @@ func TestCommandSide_AddOrg(t *testing.T) { func TestCommandSide_ChangeOrg(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -492,9 +479,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "empty name, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -507,9 +492,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "empty name (spaces), invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -523,8 +506,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "org not found, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -540,8 +522,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "no change (spaces), error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -563,8 +544,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "push failed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -593,8 +573,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "change org name verified, not primary", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -645,8 +624,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "change org name verified, with primary", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -705,8 +683,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "change org name case verified, with primary", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -754,7 +731,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } _, err := r.ChangeOrg(tt.args.ctx, tt.args.orgID, tt.args.name) if tt.res.err == nil { @@ -769,7 +746,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { func TestCommandSide_DeactivateOrg(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator iamDomain string } @@ -790,8 +767,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { { name: "org not found, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -806,8 +782,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { { name: "org already inactive, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -832,8 +807,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { { name: "push failed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -860,8 +834,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { { name: "deactivate org", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -886,7 +859,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, } _, err := r.DeactivateOrg(tt.args.ctx, tt.args.orgID) @@ -902,7 +875,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { func TestCommandSide_ReactivateOrg(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator iamDomain string } @@ -923,8 +896,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { { name: "org not found, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -939,8 +911,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { { name: "org already active, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -961,8 +932,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { { name: "push failed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -994,8 +964,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { { name: "reactivate org", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -1024,7 +993,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, } _, err := r.ReactivateOrg(tt.args.ctx, tt.args.orgID) @@ -1040,7 +1009,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { func TestCommandSide_RemoveOrg(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator } type args struct { @@ -1059,9 +1028,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "default org, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: authz.WithInstance(context.Background(), &mockInstance{}), @@ -1074,8 +1041,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "zitadel org, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -1100,8 +1066,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "org not found, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), // zitadel project check expectFilter(), ), @@ -1117,8 +1082,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "push failed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), // zitadel project check expectFilter( eventFromEventPusher( @@ -1160,8 +1124,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "remove org", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), // zitadel project check expectFilter( eventFromEventPusher( @@ -1200,8 +1163,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "remove org with usernames and domains", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), // zitadel project check expectFilter( eventFromEventPusher( @@ -1291,7 +1253,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, } _, err := r.RemoveOrg(tt.args.ctx, tt.args.orgID) diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go index 6bfeaae219..128880341a 100644 --- a/internal/command/permission_checks.go +++ b/internal/command/permission_checks.go @@ -6,6 +6,8 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/v2/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -13,6 +15,8 @@ import ( type PermissionCheck func(resourceOwner, aggregateID string) error +type UserGrantPermissionCheck func(projectID, projectGrantID string) PermissionCheck + func (c *Commands) newPermissionCheck(ctx context.Context, permission string, aggregateType eventstore.AggregateType) PermissionCheck { return func(resourceOwner, aggregateID string) error { if aggregateID == "" { @@ -85,3 +89,70 @@ func (c *Commands) checkPermissionDeleteProjectGrant(ctx context.Context, resour } return nil } + +func (c *Commands) checkPermissionUpdateApplication(ctx context.Context, resourceOwner, appID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectAppWrite, project.AggregateType)(resourceOwner, appID) +} + +func (c *Commands) checkPermissionDeleteApp(ctx context.Context, resourceOwner, appID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectAppDelete, project.AggregateType)(resourceOwner, appID) +} + +func (c *Commands) checkPermissionUpdateInstanceMember(ctx context.Context, instanceID string) error { + return c.newPermissionCheck(ctx, domain.PermissionInstanceMemberWrite, instance.AggregateType)(instanceID, instanceID) +} + +func (c *Commands) checkPermissionDeleteInstanceMember(ctx context.Context, instanceID string) error { + return c.newPermissionCheck(ctx, domain.PermissionInstanceMemberDelete, instance.AggregateType)(instanceID, instanceID) +} + +func (c *Commands) checkPermissionUpdateOrgMember(ctx context.Context, instanceID, orgID string) error { + return c.newPermissionCheck(ctx, domain.PermissionOrgMemberWrite, org.AggregateType)(instanceID, orgID) +} +func (c *Commands) checkPermissionDeleteOrgMember(ctx context.Context, instanceID, orgID string) error { + return c.newPermissionCheck(ctx, domain.PermissionOrgMemberDelete, org.AggregateType)(instanceID, orgID) +} + +func (c *Commands) checkPermissionUpdateProjectMember(ctx context.Context, resourceOwner, projectID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectMemberWrite, project.AggregateType)(resourceOwner, projectID) +} + +func (c *Commands) checkPermissionDeleteProjectMember(ctx context.Context, resourceOwner, projectID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectMemberDelete, project.AggregateType)(resourceOwner, projectID) +} + +func (c *Commands) checkPermissionUpdateProjectGrantMember(ctx context.Context, grantedOrgID, projectGrantID string) (err error) { + // TODO: add permission check for project grant owners + //if err := c.newPermissionCheck(ctx, domain.PermissionProjectGrantMemberWrite, project.AggregateType)(resourceOwner, projectGrantID); err != nil { + return c.newPermissionCheck(ctx, domain.PermissionProjectGrantMemberWrite, project.AggregateType)(grantedOrgID, projectGrantID) + //} + //return nil +} + +func (c *Commands) checkPermissionDeleteProjectGrantMember(ctx context.Context, grantedOrgID, projectGrantID string) (err error) { + // TODO: add permission check for project grant owners + //if err := c.newPermissionCheck(ctx, domain.PermissionProjectGrantMemberDelete, project.AggregateType)(resourceOwner, projectGrantID); err != nil { + return c.newPermissionCheck(ctx, domain.PermissionProjectGrantMemberDelete, project.AggregateType)(grantedOrgID, projectGrantID) + //} + //return nil +} + +func (c *Commands) newUserGrantPermissionCheck(ctx context.Context, permission string) UserGrantPermissionCheck { + check := c.newPermissionCheck(ctx, permission, project.AggregateType) + return func(projectID, projectGrantID string) PermissionCheck { + return func(resourceOwner, _ string) error { + if projectGrantID != "" { + return check(resourceOwner, projectGrantID) + } + return check(resourceOwner, projectID) + } + } +} + +func (c *Commands) NewPermissionCheckUserGrantWrite(ctx context.Context) UserGrantPermissionCheck { + return c.newUserGrantPermissionCheck(ctx, domain.PermissionUserGrantWrite) +} + +func (c *Commands) NewPermissionCheckUserGrantDelete(ctx context.Context) UserGrantPermissionCheck { + return c.newUserGrantPermissionCheck(ctx, domain.PermissionUserGrantDelete) +} diff --git a/internal/command/project.go b/internal/command/project.go index 40aa79f186..4cdf1b7373 100644 --- a/internal/command/project.go +++ b/internal/command/project.go @@ -346,7 +346,7 @@ func (c *Commands) RemoveProject(ctx context.Context, projectID, resourceOwner s } for _, grantID := range cascadingUserGrantIDs { - event, _, err := c.removeUserGrant(ctx, grantID, "", true) + event, _, err := c.removeUserGrant(ctx, grantID, "", true, false, nil) if err != nil { logging.WithFields("id", "COMMAND-b8Djf", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") continue @@ -398,7 +398,7 @@ func (c *Commands) DeleteProject(ctx context.Context, id, resourceOwner string, ), } for _, grantID := range cascadingUserGrantIDs { - event, _, err := c.removeUserGrant(ctx, grantID, "", true) + event, _, err := c.removeUserGrant(ctx, grantID, "", true, false, nil) if err != nil { logging.WithFields("id", "COMMAND-b8Djf", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") continue diff --git a/internal/command/project_application.go b/internal/command/project_application.go index 0ccf5dc852..465b12e1e1 100644 --- a/internal/command/project_application.go +++ b/internal/command/project_application.go @@ -15,7 +15,7 @@ type AddApp struct { Name string } -func (c *Commands) ChangeApplication(ctx context.Context, projectID string, appChange domain.Application, resourceOwner string) (*domain.ObjectDetails, error) { +func (c *Commands) UpdateApplicationName(ctx context.Context, projectID string, appChange domain.Application, resourceOwner string) (*domain.ObjectDetails, error) { if projectID == "" || appChange.GetAppID() == "" || appChange.GetApplicationName() == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9vS", "Errors.Project.App.Invalid") } @@ -30,6 +30,13 @@ func (c *Commands) ChangeApplication(ctx context.Context, projectID string, appC if existingApp.Name == appChange.GetApplicationName() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2m8vx", "Errors.NoChangesFound") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push( ctx, @@ -59,6 +66,13 @@ func (c *Commands) DeactivateApplication(ctx context.Context, projectID, appID, if existingApp.State != domain.AppStateActive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-dsh35", "Errors.Project.App.NotActive") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationDeactivatedEvent(ctx, projectAgg, appID)) if err != nil { @@ -86,6 +100,11 @@ func (c *Commands) ReactivateApplication(ctx context.Context, projectID, appID, if existingApp.State != domain.AppStateInactive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-1n8cM", "Errors.Project.App.NotInactive") } + + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationReactivatedEvent(ctx, projectAgg, appID)) @@ -111,6 +130,13 @@ func (c *Commands) RemoveApplication(ctx context.Context, projectID, appID, reso if existingApp.State == domain.AppStateUnspecified || existingApp.State == domain.AppStateRemoved { return nil, zerrors.ThrowNotFound(nil, "COMMAND-0po9s", "Errors.Project.App.NotExisting") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionDeleteApp(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) entityID := "" diff --git a/internal/command/project_application_api.go b/internal/command/project_application_api.go index 5b8bbafcdf..82e7d0bde8 100644 --- a/internal/command/project_application_api.go +++ b/internal/command/project_application_api.go @@ -90,16 +90,24 @@ func (c *Commands) AddAPIApplication(ctx context.Context, apiApp *domain.APIApp, return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-5m9E", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner); err != nil { + projectResOwner, err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + if !apiApp.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-Bff2g", "Errors.Project.App.Invalid") } - appID, err := c.idGenerator.Next() - if err != nil { - return nil, err + appID := apiApp.AppID + if appID == "" { + appID, err = c.idGenerator.Next() + if err != nil { + return nil, err + } } return c.addAPIApplicationWithID(ctx, apiApp, resourceOwner, appID) @@ -112,6 +120,13 @@ func (c *Commands) addAPIApplicationWithID(ctx context.Context, apiApp *domain.A apiApp.AppID = appID addedApplication := NewAPIApplicationWriteModel(apiApp.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) events := []eventstore.Command{ @@ -150,7 +165,7 @@ func (c *Commands) addAPIApplicationWithID(ctx context.Context, apiApp *domain.A return result, nil } -func (c *Commands) ChangeAPIApplication(ctx context.Context, apiApp *domain.APIApp, resourceOwner string) (*domain.APIApp, error) { +func (c *Commands) UpdateAPIApplication(ctx context.Context, apiApp *domain.APIApp, resourceOwner string) (*domain.APIApp, error) { if apiApp.AppID == "" || apiApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-1m900", "Errors.Project.App.APIConfigInvalid") } @@ -165,6 +180,13 @@ func (c *Commands) ChangeAPIApplication(ctx context.Context, apiApp *domain.APIA if !existingAPI.IsAPI() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Gnwt3", "Errors.Project.App.IsNotAPI") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingAPI); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingAPI.ResourceOwner, existingAPI.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingAPI.WriteModel) changedEvent, hasChanged, err := existingAPI.NewChangedEvent( ctx, @@ -205,6 +227,11 @@ func (c *Commands) ChangeAPIApplicationSecret(ctx context.Context, projectID, ap if !existingAPI.IsAPI() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-aeH4", "Errors.Project.App.IsNotAPI") } + + if err := c.checkPermissionUpdateApplication(ctx, existingAPI.ResourceOwner, existingAPI.AggregateID); err != nil { + return nil, err + } + encodedHash, plain, err := c.newHashedSecret(ctx, c.eventstore.Filter) //nolint:staticcheck if err != nil { return nil, err @@ -226,37 +253,6 @@ func (c *Commands) ChangeAPIApplicationSecret(ctx context.Context, projectID, ap return result, err } -func (c *Commands) VerifyAPIClientSecret(ctx context.Context, projectID, appID, secret string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - app, err := c.getAPIAppWriteModel(ctx, projectID, appID, "") - if err != nil { - return err - } - if !app.State.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-DFnbf", "Errors.Project.App.NotExisting") - } - if !app.IsAPI() { - return zerrors.ThrowInvalidArgument(nil, "COMMAND-Bf3fw", "Errors.Project.App.IsNotAPI") - } - if app.HashedSecret == "" { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-D3t5g", "Errors.Project.App.APIConfigInvalid") - } - - projectAgg := ProjectAggregateFromWriteModel(&app.WriteModel) - ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") - updated, err := c.secretHasher.Verify(app.HashedSecret, secret) - spanPasswordComparison.EndWithError(err) - if err != nil { - return zerrors.ThrowInvalidArgument(err, "COMMAND-SADfg", "Errors.Project.App.ClientSecretInvalid") - } - if updated != "" { - c.apiUpdateSecret(ctx, projectAgg, app.AppID, updated) - } - return nil -} - func (c *Commands) APIUpdateSecret(ctx context.Context, appID, projectID, resourceOwner, updated string) { agg := project_repo.NewAggregate(projectID, resourceOwner) c.apiUpdateSecret(ctx, &agg.Aggregate, appID, updated) diff --git a/internal/command/project_application_api_test.go b/internal/command/project_application_api_test.go index 2702c00b39..53448e1c5e 100644 --- a/internal/command/project_application_api_test.go +++ b/internal/command/project_application_api_test.go @@ -2,16 +2,11 @@ package command import ( "context" - "io" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zitadel/passwap" - "github.com/zitadel/passwap/bcrypt" "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -147,6 +142,7 @@ func TestAddAPIConfig(t *testing.T) { } func TestCommandSide_AddAPIApplication(t *testing.T) { + t.Parallel() type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -243,6 +239,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -297,6 +294,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -351,6 +349,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -395,6 +394,8 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -402,6 +403,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.AddAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) if tt.res.err == nil { @@ -418,6 +420,8 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { } func TestCommandSide_ChangeAPIApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore } @@ -521,6 +525,7 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { domain.APIAuthMethodTypePrivateKeyJWT), ), ), + expectFilter(), ), }, args: args{ @@ -560,6 +565,7 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { domain.APIAuthMethodTypeBasic), ), ), + expectFilter(), expectPush( newAPIAppChangedEvent(context.Background(), "app1", @@ -598,14 +604,17 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) + got, err := r.UpdateAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -620,6 +629,8 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { } func TestCommandSide_ChangeAPIApplicationSecret(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -739,12 +750,15 @@ func TestCommandSide_ChangeAPIApplicationSecret(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ChangeAPIApplicationSecret(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -771,99 +785,3 @@ func newAPIAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner ) return event } - -func TestCommands_VerifyAPIClientSecret(t *testing.T) { - hasher := &crypto.Hasher{ - Swapper: passwap.NewSwapper(bcrypt.New(bcrypt.MinCost)), - } - hashedSecret, err := hasher.Hash("secret") - require.NoError(t, err) - agg := project.NewAggregate("projectID", "orgID") - - tests := []struct { - name string - secret string - eventstore func(*testing.T) *eventstore.Eventstore - wantErr error - }{ - { - name: "filter error", - eventstore: expectEventstore( - expectFilterError(io.ErrClosedPipe), - ), - wantErr: io.ErrClosedPipe, - }, - { - name: "app not exists", - eventstore: expectEventstore( - expectFilter(), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-DFnbf", "Errors.Project.App.NotExisting"), - }, - { - name: "wrong app type", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-Bf3fw", "Errors.Project.App.IsNotAPI"), - }, - { - name: "no secret set", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewAPIConfigAddedEvent(context.Background(), &agg.Aggregate, "appID", "clientID", "", domain.APIAuthMethodTypePrivateKeyJWT), - ), - ), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D3t5g", "Errors.Project.App.APIConfigInvalid"), - }, - { - name: "check succeeded", - secret: "secret", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewAPIConfigAddedEvent(context.Background(), &agg.Aggregate, "appID", "clientID", hashedSecret, domain.APIAuthMethodTypePrivateKeyJWT), - ), - ), - ), - }, - { - name: "check failed", - secret: "wrong!", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewAPIConfigAddedEvent(context.Background(), &agg.Aggregate, "appID", "clientID", hashedSecret, domain.APIAuthMethodTypePrivateKeyJWT), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(err, "COMMAND-SADfg", "Errors.Project.App.ClientSecretInvalid"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &Commands{ - eventstore: tt.eventstore(t), - secretHasher: hasher, - } - err := c.VerifyAPIClientSecret(context.Background(), "projectID", "appID", tt.secret) - c.jobs.Wait() - require.ErrorIs(t, err, tt.wantErr) - }) - } -} diff --git a/internal/command/project_application_key.go b/internal/command/project_application_key.go index 519e9fc30a..47dacdd638 100644 --- a/internal/command/project_application_key.go +++ b/internal/command/project_application_key.go @@ -38,6 +38,11 @@ func (c *Commands) AddApplicationKey(ctx context.Context, key *domain.Applicatio if err != nil { return nil, err } + + if resourceOwner == "" { + resourceOwner = application.ResourceOwner + } + if !application.State.Exists() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-sak25", "Errors.Project.App.NotFound") } @@ -59,6 +64,10 @@ func (c *Commands) addApplicationKey(ctx context.Context, key *domain.Applicatio return nil, err } + if err := c.checkPermissionUpdateApplication(ctx, keyWriteModel.ResourceOwner, keyWriteModel.AggregateID); err != nil { + return nil, err + } + if !keyWriteModel.KeysAllowed { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Dff54", "Errors.Project.App.AuthMethodNoPrivateKeyJWT") } @@ -110,6 +119,10 @@ func (c *Commands) RemoveApplicationKey(ctx context.Context, projectID, applicat return nil, zerrors.ThrowNotFound(nil, "COMMAND-4m77G", "Errors.Project.App.Key.NotFound") } + if err := c.checkPermissionUpdateApplication(ctx, keyWriteModel.ResourceOwner, keyWriteModel.AggregateID); err != nil { + return nil, err + } + pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationKeyRemovedEvent(ctx, ProjectAggregateFromWriteModel(&keyWriteModel.WriteModel), keyID)) if err != nil { return nil, err diff --git a/internal/command/project_application_key_test.go b/internal/command/project_application_key_test.go index 9fd46c75f3..3402cab507 100644 --- a/internal/command/project_application_key_test.go +++ b/internal/command/project_application_key_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/domain" + permissionmock "github.com/zitadel/zitadel/internal/domain/mock" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/id" @@ -17,9 +18,10 @@ import ( func TestCommandSide_AddAPIApplicationKey(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator - keySize int + eventstore func(*testing.T) *eventstore.Eventstore + idGenerator id.Generator + keySize int + permissionCheckMock domain.PermissionCheck } type args struct { ctx context.Context @@ -39,9 +41,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { { name: "no aggregateid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -57,9 +58,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { { name: "no appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -77,10 +77,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -97,10 +95,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { }, }, { - name: "create key not allowed, precondition error", + name: "create key not allowed, precondition error 1", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -121,7 +118,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -138,10 +136,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { }, }, { - name: "create key not allowed, precondition error", + name: "permission check failed", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -162,8 +159,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), - keySize: 10, + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + keySize: 10, + permissionCheckMock: permissionmock.MockPermissionCheckErr(zerrors.ThrowPermissionDenied(nil, "mock.err", "mock permission check failed")), }, args: args{ ctx: context.Background(), @@ -175,6 +173,47 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { }, resourceOwner: "org1", }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "create key not allowed, precondition error 2", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + ), + ), + ), + expectFilter( + eventFromEventPusher( + project.NewAPIConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "client1@project", + "secret", + domain.APIAuthMethodTypeBasic), + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + keySize: 10, + permissionCheckMock: permissionmock.MockPermissionCheckOK(), + }, + args: args{ + ctx: context.Background(), + key: &domain.ApplicationKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + ApplicationID: "app1", + }, + }, res: res{ err: zerrors.IsPreconditionFailed, }, @@ -183,9 +222,10 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, applicationKeySize: tt.fields.keySize, + checkPermission: tt.fields.permissionCheckMock, } got, err := r.AddApplicationKey(tt.args.ctx, tt.args.key, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index 491bd38fca..7f33b6a3cf 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/muhlemmer/gu" + http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" @@ -120,6 +122,7 @@ func (c *Commands) AddOIDCAppCommand(app *addOIDCApp) preparation.Validation { } } +// TODO: Combine with AddOIDCApplication and addOIDCApplicationWithID func (c *Commands) AddOIDCApplicationWithID(ctx context.Context, oidcApp *domain.OIDCApp, resourceOwner, appID string) (_ *domain.OIDCApp, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -142,9 +145,15 @@ func (c *Commands) AddOIDCApplication(ctx context.Context, oidcApp *domain.OIDCA if oidcApp == nil || oidcApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-34Fm0", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner); err != nil { + + projectResOwner, err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + if oidcApp.AppName == "" || !oidcApp.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1n8df", "Errors.Project.App.Invalid") } @@ -162,6 +171,13 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain defer func() { span.EndWithError(err) }() addedApplication := NewOIDCApplicationWriteModel(oidcApp.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) oidcApp.AppID = appID @@ -183,27 +199,27 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain } events = append(events, project_repo.NewOIDCConfigAddedEvent(ctx, projectAgg, - oidcApp.OIDCVersion, + gu.Value(oidcApp.OIDCVersion), oidcApp.AppID, oidcApp.ClientID, oidcApp.EncodedHash, trimStringSliceWhiteSpaces(oidcApp.RedirectUris), oidcApp.ResponseTypes, oidcApp.GrantTypes, - oidcApp.ApplicationType, - oidcApp.AuthMethodType, + gu.Value(oidcApp.ApplicationType), + gu.Value(oidcApp.AuthMethodType), trimStringSliceWhiteSpaces(oidcApp.PostLogoutRedirectUris), - oidcApp.DevMode, - oidcApp.AccessTokenType, - oidcApp.AccessTokenRoleAssertion, - oidcApp.IDTokenRoleAssertion, - oidcApp.IDTokenUserinfoAssertion, - oidcApp.ClockSkew, + gu.Value(oidcApp.DevMode), + gu.Value(oidcApp.AccessTokenType), + gu.Value(oidcApp.AccessTokenRoleAssertion), + gu.Value(oidcApp.IDTokenRoleAssertion), + gu.Value(oidcApp.IDTokenUserinfoAssertion), + gu.Value(oidcApp.ClockSkew), trimStringSliceWhiteSpaces(oidcApp.AdditionalOrigins), - oidcApp.SkipNativeAppSuccessPage, - strings.TrimSpace(oidcApp.BackChannelLogoutURI), - oidcApp.LoginVersion, - strings.TrimSpace(oidcApp.LoginBaseURI), + gu.Value(oidcApp.SkipNativeAppSuccessPage), + strings.TrimSpace(gu.Value(oidcApp.BackChannelLogoutURI)), + gu.Value(oidcApp.LoginVersion), + strings.TrimSpace(gu.Value(oidcApp.LoginBaseURI)), )) addedApplication.AppID = oidcApp.AppID @@ -226,7 +242,7 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain return result, nil } -func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCApp, resourceOwner string) (*domain.OIDCApp, error) { +func (c *Commands) UpdateOIDCApplication(ctx context.Context, oidc *domain.OIDCApp, resourceOwner string) (*domain.OIDCApp, error) { if !oidc.IsValid() || oidc.AppID == "" || oidc.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-5m9fs", "Errors.Project.App.OIDCConfigInvalid") } @@ -241,7 +257,23 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA if !existingOIDC.IsOIDC() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-GBr34", "Errors.Project.App.IsNotOIDC") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingOIDC); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingOIDC.ResourceOwner, existingOIDC.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingOIDC.WriteModel) + var backChannelLogout, loginBaseURI *string + if oidc.BackChannelLogoutURI != nil { + backChannelLogout = gu.Ptr(strings.TrimSpace(*oidc.BackChannelLogoutURI)) + } + + if oidc.LoginBaseURI != nil { + loginBaseURI = gu.Ptr(strings.TrimSpace(*oidc.LoginBaseURI)) + } + changedEvent, hasChanged, err := existingOIDC.NewChangedEvent( ctx, projectAgg, @@ -261,9 +293,9 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA oidc.ClockSkew, trimStringSliceWhiteSpaces(oidc.AdditionalOrigins), oidc.SkipNativeAppSuccessPage, - strings.TrimSpace(oidc.BackChannelLogoutURI), + backChannelLogout, oidc.LoginVersion, - strings.TrimSpace(oidc.LoginBaseURI), + loginBaseURI, ) if err != nil { return nil, err @@ -301,6 +333,11 @@ func (c *Commands) ChangeOIDCApplicationSecret(ctx context.Context, projectID, a if !existingOIDC.IsOIDC() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Ghrh3", "Errors.Project.App.IsNotOIDC") } + + if err := c.checkPermissionUpdateApplication(ctx, existingOIDC.ResourceOwner, existingOIDC.AggregateID); err != nil { + return nil, err + } + encodedHash, plain, err := c.newHashedSecret(ctx, c.eventstore.Filter) //nolint:staticcheck if err != nil { return nil, err @@ -322,37 +359,6 @@ func (c *Commands) ChangeOIDCApplicationSecret(ctx context.Context, projectID, a return result, err } -func (c *Commands) VerifyOIDCClientSecret(ctx context.Context, projectID, appID, secret string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - app, err := c.getOIDCAppWriteModel(ctx, projectID, appID, "") - if err != nil { - return err - } - if !app.State.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-D8hba", "Errors.Project.App.NotExisting") - } - if !app.IsOIDC() { - return zerrors.ThrowInvalidArgument(nil, "COMMAND-BHgn2", "Errors.Project.App.IsNotOIDC") - } - if app.HashedSecret == "" { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-D6hba", "Errors.Project.App.OIDCConfigInvalid") - } - - projectAgg := ProjectAggregateFromWriteModel(&app.WriteModel) - ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") - updated, err := c.secretHasher.Verify(app.HashedSecret, secret) - spanPasswordComparison.EndWithError(err) - if err != nil { - return zerrors.ThrowInvalidArgument(err, "COMMAND-Bz542", "Errors.Project.App.ClientSecretInvalid") - } - if updated != "" { - c.oidcUpdateSecret(ctx, projectAgg, appID, updated) - } - return nil -} - func (c *Commands) OIDCUpdateSecret(ctx context.Context, appID, projectID, resourceOwner, updated string) { agg := project_repo.NewAggregate(projectID, resourceOwner) c.oidcUpdateSecret(ctx, &agg.Aggregate, appID, updated) diff --git a/internal/command/project_application_oidc_model.go b/internal/command/project_application_oidc_model.go index 603ebdcda2..375bb26f5e 100644 --- a/internal/command/project_application_oidc_model.go +++ b/internal/command/project_application_oidc_model.go @@ -258,77 +258,77 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent( postLogoutRedirectURIs []string, responseTypes []domain.OIDCResponseType, grantTypes []domain.OIDCGrantType, - appType domain.OIDCApplicationType, - authMethodType domain.OIDCAuthMethodType, - oidcVersion domain.OIDCVersion, - accessTokenType domain.OIDCTokenType, + appType *domain.OIDCApplicationType, + authMethodType *domain.OIDCAuthMethodType, + oidcVersion *domain.OIDCVersion, + accessTokenType *domain.OIDCTokenType, devMode, accessTokenRoleAssertion, idTokenRoleAssertion, - idTokenUserinfoAssertion bool, - clockSkew time.Duration, + idTokenUserinfoAssertion *bool, + clockSkew *time.Duration, additionalOrigins []string, - skipNativeAppSuccessPage bool, - backChannelLogoutURI string, - loginVersion domain.LoginVersion, - loginBaseURI string, + skipNativeAppSuccessPage *bool, + backChannelLogoutURI *string, + loginVersion *domain.LoginVersion, + loginBaseURI *string, ) (*project.OIDCConfigChangedEvent, bool, error) { changes := make([]project.OIDCConfigChanges, 0) var err error - if !slices.Equal(wm.RedirectUris, redirectURIS) { + if redirectURIS != nil && !slices.Equal(wm.RedirectUris, redirectURIS) { changes = append(changes, project.ChangeRedirectURIs(redirectURIS)) } - if !slices.Equal(wm.ResponseTypes, responseTypes) { + if responseTypes != nil && !slices.Equal(wm.ResponseTypes, responseTypes) { changes = append(changes, project.ChangeResponseTypes(responseTypes)) } - if !slices.Equal(wm.GrantTypes, grantTypes) { + if grantTypes != nil && !slices.Equal(wm.GrantTypes, grantTypes) { changes = append(changes, project.ChangeGrantTypes(grantTypes)) } - if wm.ApplicationType != appType { - changes = append(changes, project.ChangeApplicationType(appType)) + if appType != nil && wm.ApplicationType != *appType { + changes = append(changes, project.ChangeApplicationType(*appType)) } - if wm.AuthMethodType != authMethodType { - changes = append(changes, project.ChangeAuthMethodType(authMethodType)) + if authMethodType != nil && wm.AuthMethodType != *authMethodType { + changes = append(changes, project.ChangeAuthMethodType(*authMethodType)) } - if !slices.Equal(wm.PostLogoutRedirectUris, postLogoutRedirectURIs) { + if postLogoutRedirectURIs != nil && !slices.Equal(wm.PostLogoutRedirectUris, postLogoutRedirectURIs) { changes = append(changes, project.ChangePostLogoutRedirectURIs(postLogoutRedirectURIs)) } - if wm.OIDCVersion != oidcVersion { - changes = append(changes, project.ChangeVersion(oidcVersion)) + if oidcVersion != nil && wm.OIDCVersion != *oidcVersion { + changes = append(changes, project.ChangeVersion(*oidcVersion)) } - if wm.DevMode != devMode { - changes = append(changes, project.ChangeDevMode(devMode)) + if devMode != nil && wm.DevMode != *devMode { + changes = append(changes, project.ChangeDevMode(*devMode)) } - if wm.AccessTokenType != accessTokenType { - changes = append(changes, project.ChangeAccessTokenType(accessTokenType)) + if accessTokenType != nil && wm.AccessTokenType != *accessTokenType { + changes = append(changes, project.ChangeAccessTokenType(*accessTokenType)) } - if wm.AccessTokenRoleAssertion != accessTokenRoleAssertion { - changes = append(changes, project.ChangeAccessTokenRoleAssertion(accessTokenRoleAssertion)) + if accessTokenRoleAssertion != nil && wm.AccessTokenRoleAssertion != *accessTokenRoleAssertion { + changes = append(changes, project.ChangeAccessTokenRoleAssertion(*accessTokenRoleAssertion)) } - if wm.IDTokenRoleAssertion != idTokenRoleAssertion { - changes = append(changes, project.ChangeIDTokenRoleAssertion(idTokenRoleAssertion)) + if idTokenRoleAssertion != nil && wm.IDTokenRoleAssertion != *idTokenRoleAssertion { + changes = append(changes, project.ChangeIDTokenRoleAssertion(*idTokenRoleAssertion)) } - if wm.IDTokenUserinfoAssertion != idTokenUserinfoAssertion { - changes = append(changes, project.ChangeIDTokenUserinfoAssertion(idTokenUserinfoAssertion)) + if idTokenUserinfoAssertion != nil && wm.IDTokenUserinfoAssertion != *idTokenUserinfoAssertion { + changes = append(changes, project.ChangeIDTokenUserinfoAssertion(*idTokenUserinfoAssertion)) } - if wm.ClockSkew != clockSkew { - changes = append(changes, project.ChangeClockSkew(clockSkew)) + if clockSkew != nil && wm.ClockSkew != *clockSkew { + changes = append(changes, project.ChangeClockSkew(*clockSkew)) } - if !slices.Equal(wm.AdditionalOrigins, additionalOrigins) { + if additionalOrigins != nil && !slices.Equal(wm.AdditionalOrigins, additionalOrigins) { changes = append(changes, project.ChangeAdditionalOrigins(additionalOrigins)) } - if wm.SkipNativeAppSuccessPage != skipNativeAppSuccessPage { - changes = append(changes, project.ChangeSkipNativeAppSuccessPage(skipNativeAppSuccessPage)) + if skipNativeAppSuccessPage != nil && wm.SkipNativeAppSuccessPage != *skipNativeAppSuccessPage { + changes = append(changes, project.ChangeSkipNativeAppSuccessPage(*skipNativeAppSuccessPage)) } - if wm.BackChannelLogoutURI != backChannelLogoutURI { - changes = append(changes, project.ChangeBackChannelLogoutURI(backChannelLogoutURI)) + if backChannelLogoutURI != nil && wm.BackChannelLogoutURI != *backChannelLogoutURI { + changes = append(changes, project.ChangeBackChannelLogoutURI(*backChannelLogoutURI)) } - if wm.LoginVersion != loginVersion { - changes = append(changes, project.ChangeOIDCLoginVersion(loginVersion)) + if loginVersion != nil && wm.LoginVersion != *loginVersion { + changes = append(changes, project.ChangeOIDCLoginVersion(*loginVersion)) } - if wm.LoginBaseURI != loginBaseURI { - changes = append(changes, project.ChangeOIDCLoginBaseURI(loginBaseURI)) + if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI { + changes = append(changes, project.ChangeOIDCLoginBaseURI(*loginBaseURI)) } if len(changes) == 0 { diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index 4b9f5bf94f..d728ffca45 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -2,18 +2,14 @@ package command import ( "context" - "io" "testing" "time" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zitadel/passwap" - "github.com/zitadel/passwap/bcrypt" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -406,6 +402,8 @@ func TestAddOIDCApp(t *testing.T) { } func TestCommandSide_AddOIDCApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -502,6 +500,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -543,24 +542,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{" https://test.ch "}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{" https://test.ch/logout "}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{" https://sub.test.ch "}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: " https://test.ch/backchannel ", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: " https://login.test.ch ", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr(" https://test.ch/backchannel "), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr(" https://login.test.ch "), }, resourceOwner: "org1", }, @@ -574,24 +573,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AppName: "app", ClientID: "client1", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), State: domain.AppStateActive, Compliance: &domain.Compliance{}, }, @@ -609,6 +608,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -650,24 +650,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), }, resourceOwner: "org1", }, @@ -681,24 +681,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AppName: "app", ClientID: "client1", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), State: domain.AppStateActive, Compliance: &domain.Compliance{}, }, @@ -707,6 +707,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() c := &Commands{ eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -714,6 +715,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } c.setMilestonesCompletedForTest("instanceID") got, err := c.AddOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) @@ -731,6 +733,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { } func TestCommandSide_ChangeOIDCApplication(t *testing.T) { + t.Parallel() type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -780,7 +783,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppID: "", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -802,7 +805,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "", }, AppID: "appid", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -826,7 +829,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppID: "app1", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -875,6 +878,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), ), }, args: args{ @@ -885,24 +889,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, AppID: "app1", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), }, resourceOwner: "org1", }, @@ -949,6 +953,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), ), }, args: args{ @@ -959,24 +964,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, AppID: "app1", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch "}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{" https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{" https://sub.test.ch "}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: " https://test.ch/backchannel ", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: " https://login.test.ch ", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr(" https://test.ch/backchannel "), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr(" https://login.test.ch "), }, resourceOwner: "org1", }, @@ -985,7 +990,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, }, { - name: "change oidc app, ok", + name: "partial change oidc app, ok", fields: fields{ eventstore: expectEventstore( expectFilter( @@ -1023,6 +1028,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), expectPush( newOIDCAppChangedEvent(context.Background(), "app1", @@ -1037,26 +1043,11 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, - AppID: "app1", - AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, - RedirectUris: []string{" https://test-change.ch "}, - ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, - PostLogoutRedirectUris: []string{" https://test-change.ch/logout "}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeJWT, - AccessTokenRoleAssertion: false, - IDTokenRoleAssertion: false, - IDTokenUserinfoAssertion: false, - ClockSkew: time.Second * 2, - AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + AppID: "app1", + AppName: "app", + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, resourceOwner: "org1", }, @@ -1069,24 +1060,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AppID: "app1", ClientID: "client1@project", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, - RedirectUris: []string{"https://test-change.ch"}, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, - PostLogoutRedirectUris: []string{"https://test-change.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeJWT, - AccessTokenRoleAssertion: false, - IDTokenRoleAssertion: false, - IDTokenUserinfoAssertion: false, - ClockSkew: time.Second * 2, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + PostLogoutRedirectUris: []string{"https://test.ch/logout"}, + DevMode: gu.Ptr(false), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion1), + LoginBaseURI: gu.Ptr(""), Compliance: &domain.Compliance{}, State: domain.AppStateActive, }, @@ -1095,10 +1086,12 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // t.Parallel() r := &Commands{ - eventstore: tt.fields.eventstore(t), + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) + got, err := r.UpdateOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -1113,6 +1106,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { } func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -1242,36 +1237,40 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { AppName: "app", ClientID: "client1@project", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: false, - BackChannelLogoutURI: "", - LoginVersion: domain.LoginVersionUnspecified, + SkipNativeAppSuccessPage: gu.Ptr(false), + BackChannelLogoutURI: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), State: domain.AppStateActive, }, }, }, } for _, tt := range tests { - t.Run(tt.name, func(*testing.T) { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ChangeOIDCApplicationSecret(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -1289,16 +1288,7 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { func newOIDCAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner string) *project.OIDCConfigChangedEvent { changes := []project.OIDCConfigChanges{ - project.ChangeRedirectURIs([]string{"https://test-change.ch"}), - project.ChangePostLogoutRedirectURIs([]string{"https://test-change.ch/logout"}), - project.ChangeDevMode(true), - project.ChangeAccessTokenType(domain.OIDCTokenTypeJWT), - project.ChangeAccessTokenRoleAssertion(false), - project.ChangeIDTokenRoleAssertion(false), - project.ChangeIDTokenUserinfoAssertion(false), - project.ChangeClockSkew(time.Second * 2), - project.ChangeOIDCLoginVersion(domain.LoginVersion2), - project.ChangeOIDCLoginBaseURI("https://login.test.ch"), + project.ChangeAuthMethodType(domain.OIDCAuthMethodTypeBasic), } event, _ := project.NewOIDCConfigChangedEvent(ctx, &project.NewAggregate(projectID, resourceOwner).Aggregate, @@ -1307,168 +1297,3 @@ func newOIDCAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner ) return event } - -func TestCommands_VerifyOIDCClientSecret(t *testing.T) { - hasher := &crypto.Hasher{ - Swapper: passwap.NewSwapper(bcrypt.New(bcrypt.MinCost)), - } - hashedSecret, err := hasher.Hash("secret") - require.NoError(t, err) - agg := project.NewAggregate("projectID", "orgID") - - tests := []struct { - name string - secret string - eventstore func(*testing.T) *eventstore.Eventstore - wantErr error - }{ - { - name: "filter error", - eventstore: expectEventstore( - expectFilterError(io.ErrClosedPipe), - ), - wantErr: io.ErrClosedPipe, - }, - { - name: "app not exists", - eventstore: expectEventstore( - expectFilter(), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D8hba", "Errors.Project.App.NotExisting"), - }, - { - name: "wrong app type", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-BHgn2", "Errors.Project.App.IsNotOIDC"), - }, - { - name: "no secret set", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewOIDCConfigAddedEvent(context.Background(), - &agg.Aggregate, - domain.OIDCVersionV1, - "appID", - "client1@project", - "", - []string{"https://test.ch"}, - []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - domain.OIDCApplicationTypeWeb, - domain.OIDCAuthMethodTypePost, - []string{"https://test.ch/logout"}, - true, - domain.OIDCTokenTypeBearer, - true, - true, - true, - time.Second*1, - []string{"https://sub.test.ch"}, - false, - "", - domain.LoginVersionUnspecified, - "", - ), - ), - ), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D6hba", "Errors.Project.App.OIDCConfigInvalid"), - }, - { - name: "check succeeded", - secret: "secret", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewOIDCConfigAddedEvent(context.Background(), - &agg.Aggregate, - domain.OIDCVersionV1, - "appID", - "client1@project", - hashedSecret, - []string{"https://test.ch"}, - []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - domain.OIDCApplicationTypeWeb, - domain.OIDCAuthMethodTypePost, - []string{"https://test.ch/logout"}, - true, - domain.OIDCTokenTypeBearer, - true, - true, - true, - time.Second*1, - []string{"https://sub.test.ch"}, - false, - "", - domain.LoginVersionUnspecified, - "", - ), - ), - ), - ), - }, - { - name: "check failed", - secret: "wrong!", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewOIDCConfigAddedEvent(context.Background(), - &agg.Aggregate, - domain.OIDCVersionV1, - "appID", - "client1@project", - hashedSecret, - []string{"https://test.ch"}, - []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - domain.OIDCApplicationTypeWeb, - domain.OIDCAuthMethodTypePost, - []string{"https://test.ch/logout"}, - true, - domain.OIDCTokenTypeBearer, - true, - true, - true, - time.Second*1, - []string{"https://sub.test.ch"}, - false, - "", - domain.LoginVersionUnspecified, - "", - ), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(err, "COMMAND-Bz542", "Errors.Project.App.ClientSecretInvalid"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &Commands{ - eventstore: tt.eventstore(t), - secretHasher: hasher, - } - err := c.VerifyOIDCClientSecret(context.Background(), "projectID", "appID", tt.secret) - c.jobs.Wait() - require.ErrorIs(t, err, tt.wantErr) - }) - } -} diff --git a/internal/command/project_application_saml.go b/internal/command/project_application_saml.go index 1a5cefa221..9b1dc9e97a 100644 --- a/internal/command/project_application_saml.go +++ b/internal/command/project_application_saml.go @@ -3,6 +3,7 @@ package command import ( "context" + "github.com/muhlemmer/gu" "github.com/zitadel/saml/pkg/provider/xml" "github.com/zitadel/zitadel/internal/domain" @@ -16,10 +17,22 @@ func (c *Commands) AddSAMLApplication(ctx context.Context, application *domain.S return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-35Fn0", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner); err != nil { + projectResOwner, err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + addedApplication := NewSAMLApplicationWriteModel(application.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) events, err := c.addSAMLApplication(ctx, projectAgg, application) if err != nil { @@ -49,12 +62,8 @@ func (c *Commands) addSAMLApplication(ctx context.Context, projectAgg *eventstor return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1n9df", "Errors.Project.App.Invalid") } - if samlApp.Metadata == nil && samlApp.MetadataURL == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SAML-podix9", "Errors.Project.App.SAMLMetadataMissing") - } - - if samlApp.MetadataURL != "" { - data, err := xml.ReadMetadataFromURL(c.httpClient, samlApp.MetadataURL) + if samlApp.MetadataURL != nil && *samlApp.MetadataURL != "" { + data, err := xml.ReadMetadataFromURL(c.httpClient, *samlApp.MetadataURL) if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "SAML-wmqlo1", "Errors.Project.App.SAMLMetadataMissing") } @@ -78,14 +87,14 @@ func (c *Commands) addSAMLApplication(ctx context.Context, projectAgg *eventstor samlApp.AppID, string(entity.EntityID), samlApp.Metadata, - samlApp.MetadataURL, - samlApp.LoginVersion, - samlApp.LoginBaseURI, + gu.Value(samlApp.MetadataURL), + gu.Value(samlApp.LoginVersion), + gu.Value(samlApp.LoginBaseURI), ), }, nil } -func (c *Commands) ChangeSAMLApplication(ctx context.Context, samlApp *domain.SAMLApp, resourceOwner string) (*domain.SAMLApp, error) { +func (c *Commands) UpdateSAMLApplication(ctx context.Context, samlApp *domain.SAMLApp, resourceOwner string) (*domain.SAMLApp, error) { if !samlApp.IsValid() || samlApp.AppID == "" || samlApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-5n9fs", "Errors.Project.App.SAMLConfigInvalid") } @@ -100,10 +109,15 @@ func (c *Commands) ChangeSAMLApplication(ctx context.Context, samlApp *domain.SA if !existingSAML.IsSAML() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-GBr35", "Errors.Project.App.IsNotSAML") } + + if err := c.checkPermissionUpdateApplication(ctx, existingSAML.ResourceOwner, existingSAML.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingSAML.WriteModel) - if samlApp.MetadataURL != "" { - data, err := xml.ReadMetadataFromURL(c.httpClient, samlApp.MetadataURL) + if samlApp.MetadataURL != nil && *samlApp.MetadataURL != "" { + data, err := xml.ReadMetadataFromURL(c.httpClient, *samlApp.MetadataURL) if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "SAML-J3kg3", "Errors.Project.App.SAMLMetadataMissing") } diff --git a/internal/command/project_application_saml_model.go b/internal/command/project_application_saml_model.go index f219039b58..f3097914f3 100644 --- a/internal/command/project_application_saml_model.go +++ b/internal/command/project_application_saml_model.go @@ -2,7 +2,7 @@ package command import ( "context" - "reflect" + "slices" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -170,26 +170,26 @@ func (wm *SAMLApplicationWriteModel) NewChangedEvent( appID string, entityID string, metadata []byte, - metadataURL string, - loginVersion domain.LoginVersion, - loginBaseURI string, + metadataURL *string, + loginVersion *domain.LoginVersion, + loginBaseURI *string, ) (*project.SAMLConfigChangedEvent, bool, error) { changes := make([]project.SAMLConfigChanges, 0) var err error - if !reflect.DeepEqual(wm.Metadata, metadata) { + if metadata != nil && !slices.Equal(wm.Metadata, metadata) { changes = append(changes, project.ChangeMetadata(metadata)) } - if wm.MetadataURL != metadataURL { - changes = append(changes, project.ChangeMetadataURL(metadataURL)) + if metadataURL != nil && wm.MetadataURL != *metadataURL { + changes = append(changes, project.ChangeMetadataURL(*metadataURL)) } if wm.EntityID != entityID { changes = append(changes, project.ChangeEntityID(entityID)) } - if wm.LoginVersion != loginVersion { - changes = append(changes, project.ChangeSAMLLoginVersion(loginVersion)) + if loginVersion != nil && wm.LoginVersion != *loginVersion { + changes = append(changes, project.ChangeSAMLLoginVersion(*loginVersion)) } - if wm.LoginBaseURI != loginBaseURI { - changes = append(changes, project.ChangeSAMLLoginBaseURI(loginBaseURI)) + if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI { + changes = append(changes, project.ChangeSAMLLoginBaseURI(*loginBaseURI)) } if len(changes) == 0 { diff --git a/internal/command/project_application_saml_test.go b/internal/command/project_application_saml_test.go index c6f6f7cf21..5d18d9587c 100644 --- a/internal/command/project_application_saml_test.go +++ b/internal/command/project_application_saml_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/api/authz" @@ -49,6 +50,8 @@ var testMetadataChangedEntityID = []byte(` `) func TestCommandSide_AddSAMLApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -117,6 +120,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), }, args: args{ @@ -134,6 +138,37 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { err: zerrors.IsErrorInvalidArgument, }, }, + { + name: "empty metas, invalid argument error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingUnspecified), + ), + ), + expectFilter(), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlApp: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + AppName: "app", + EntityID: "https://test.com/saml/metadata", + }, + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, { name: "create saml app, metadata not parsable", fields: fields{ @@ -146,6 +181,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t), }, @@ -158,7 +194,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: []byte("test metadata"), - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -178,6 +214,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -206,7 +243,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -216,12 +253,14 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test.com/saml/metadata", - Metadata: testMetadata, - MetadataURL: "", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test.com/saml/metadata", + Metadata: testMetadata, + MetadataURL: gu.Ptr(""), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, @@ -237,6 +276,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -265,9 +305,9 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, resourceOwner: "org1", }, @@ -281,10 +321,10 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), State: domain.AppStateActive, - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, }, }, @@ -300,6 +340,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -329,7 +370,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -339,12 +380,14 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test.com/saml/metadata", - Metadata: testMetadata, - MetadataURL: "http://localhost:8080/saml/metadata", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test.com/saml/metadata", + Metadata: testMetadata, + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, @@ -360,6 +403,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t), httpClient: newTestClient(http.StatusNotFound, nil), @@ -373,7 +417,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -385,10 +429,13 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &Commands{ - eventstore: tt.fields.eventstore(t), - idGenerator: tt.fields.idGenerator, - httpClient: tt.fields.httpClient, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + httpClient: tt.fields.httpClient, + checkPermission: newMockPermissionCheckAllowed(), } c.setMilestonesCompletedForTest("instanceID") got, err := c.AddSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) @@ -406,6 +453,8 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { } func TestCommandSide_ChangeSAMLApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore httpClient *http.Client @@ -544,7 +593,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppID: "app1", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -590,7 +639,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppID: "app1", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -646,7 +695,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -656,17 +705,19 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test2.com/saml/metadata", - Metadata: testMetadataChangedEntityID, - MetadataURL: "http://localhost:8080/saml/metadata", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test2.com/saml/metadata", + Metadata: testMetadataChangedEntityID, + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, { - name: "change saml app, ok, metadata", + name: "partial change saml app, ok, metadata", fields: fields{ eventstore: expectEventstore( expectFilter( @@ -713,7 +764,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -723,15 +774,18 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test2.com/saml/metadata", - Metadata: testMetadataChangedEntityID, - MetadataURL: "", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test2.com/saml/metadata", + Metadata: testMetadataChangedEntityID, + MetadataURL: gu.Ptr(""), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, - }, { + }, + { name: "change saml app, ok, loginversion", fields: fields{ eventstore: expectEventstore( @@ -781,9 +835,9 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, resourceOwner: "org1", }, @@ -797,10 +851,10 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", + MetadataURL: gu.Ptr(""), State: domain.AppStateActive, - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, }, }, @@ -808,11 +862,14 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore(t), - httpClient: tt.fields.httpClient, + eventstore: tt.fields.eventstore(t), + httpClient: tt.fields.httpClient, + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) + got, err := r.UpdateSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/project_application_test.go b/internal/command/project_application_test.go index 050a41d29f..a67e6886ed 100644 --- a/internal/command/project_application_test.go +++ b/internal/command/project_application_test.go @@ -8,13 +8,16 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository/mock" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/zerrors" ) func TestCommandSide_ChangeApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -35,9 +38,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -55,9 +56,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -74,9 +73,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing name, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -94,10 +91,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -115,8 +109,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app name not changed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -142,8 +135,14 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + ), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -179,10 +178,13 @@ func TestCommandSide_ChangeApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeApplication(tt.args.ctx, tt.args.projectID, tt.args.app, tt.args.resourceOwner) + got, err := r.UpdateApplicationName(tt.args.ctx, tt.args.projectID, tt.args.app, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -197,8 +199,10 @@ func TestCommandSide_ChangeApplication(t *testing.T) { } func TestCommandSide_DeactivateApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -219,9 +223,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -236,9 +238,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -253,8 +253,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -271,8 +270,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app already inactive, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -299,8 +297,14 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app deactivate, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + ), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -331,8 +335,11 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.DeactivateApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -349,8 +356,10 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { } func TestCommandSide_ReactivateApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -371,9 +380,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -388,9 +395,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -405,10 +410,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -423,8 +425,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app already active, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -447,8 +448,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app reactivate, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -483,8 +483,11 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ReactivateApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -501,8 +504,10 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { } func TestCommandSide_RemoveApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -523,9 +528,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -540,9 +543,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -557,10 +558,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -575,8 +573,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app remove, entityID, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -584,6 +581,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { "app", )), ), + expectFilter(), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -625,8 +623,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -636,6 +633,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { ), // app is not saml, or no saml config available expectFilter(), + expectFilter(), expectPush( project.NewApplicationRemovedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -661,8 +659,11 @@ func TestCommandSide_RemoveApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.RemoveApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/project_converter.go b/internal/command/project_converter.go index 01b5a4e63d..e88a1cb75a 100644 --- a/internal/command/project_converter.go +++ b/internal/command/project_converter.go @@ -1,6 +1,8 @@ package command import ( + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/domain" ) @@ -35,21 +37,21 @@ func oidcWriteModelToOIDCConfig(writeModel *OIDCApplicationWriteModel) *domain.O RedirectUris: writeModel.RedirectUris, ResponseTypes: writeModel.ResponseTypes, GrantTypes: writeModel.GrantTypes, - ApplicationType: writeModel.ApplicationType, - AuthMethodType: writeModel.AuthMethodType, + ApplicationType: gu.Ptr(writeModel.ApplicationType), + AuthMethodType: gu.Ptr(writeModel.AuthMethodType), PostLogoutRedirectUris: writeModel.PostLogoutRedirectUris, - OIDCVersion: writeModel.OIDCVersion, - DevMode: writeModel.DevMode, - AccessTokenType: writeModel.AccessTokenType, - AccessTokenRoleAssertion: writeModel.AccessTokenRoleAssertion, - IDTokenRoleAssertion: writeModel.IDTokenRoleAssertion, - IDTokenUserinfoAssertion: writeModel.IDTokenUserinfoAssertion, - ClockSkew: writeModel.ClockSkew, + OIDCVersion: gu.Ptr(writeModel.OIDCVersion), + DevMode: gu.Ptr(writeModel.DevMode), + AccessTokenType: gu.Ptr(writeModel.AccessTokenType), + AccessTokenRoleAssertion: gu.Ptr(writeModel.AccessTokenRoleAssertion), + IDTokenRoleAssertion: gu.Ptr(writeModel.IDTokenRoleAssertion), + IDTokenUserinfoAssertion: gu.Ptr(writeModel.IDTokenUserinfoAssertion), + ClockSkew: gu.Ptr(writeModel.ClockSkew), AdditionalOrigins: writeModel.AdditionalOrigins, - SkipNativeAppSuccessPage: writeModel.SkipNativeAppSuccessPage, - BackChannelLogoutURI: writeModel.BackChannelLogoutURI, - LoginVersion: writeModel.LoginVersion, - LoginBaseURI: writeModel.LoginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(writeModel.SkipNativeAppSuccessPage), + BackChannelLogoutURI: gu.Ptr(writeModel.BackChannelLogoutURI), + LoginVersion: gu.Ptr(writeModel.LoginVersion), + LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI), } } @@ -60,10 +62,10 @@ func samlWriteModelToSAMLConfig(writeModel *SAMLApplicationWriteModel) *domain.S AppName: writeModel.AppName, State: writeModel.State, Metadata: writeModel.Metadata, - MetadataURL: writeModel.MetadataURL, + MetadataURL: gu.Ptr(writeModel.MetadataURL), EntityID: writeModel.EntityID, - LoginVersion: writeModel.LoginVersion, - LoginBaseURI: writeModel.LoginBaseURI, + LoginVersion: gu.Ptr(writeModel.LoginVersion), + LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI), } } @@ -78,15 +80,6 @@ func apiWriteModelToAPIConfig(writeModel *APIApplicationWriteModel) *domain.APIA } } -func roleWriteModelToRole(writeModel *ProjectRoleWriteModel) *domain.ProjectRole { - return &domain.ProjectRole{ - ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), - Key: writeModel.Key, - DisplayName: writeModel.DisplayName, - Group: writeModel.Group, - } -} - func memberWriteModelToProjectGrantMember(writeModel *ProjectGrantMemberWriteModel) *domain.ProjectGrantMember { return &domain.ProjectGrantMember{ ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), diff --git a/internal/command/project_grant.go b/internal/command/project_grant.go index b613974b7e..2a0e83a6e8 100644 --- a/internal/command/project_grant.go +++ b/internal/command/project_grant.go @@ -234,6 +234,17 @@ func (c *Commands) DeactivateProjectGrant(ctx context.Context, projectID, grantI return writeModelToObjectDetails(&existingGrant.WriteModel), nil } +func (c *Commands) checkProjectGrantExists(ctx context.Context, grantID, grantedOrgID, projectID, resourceOwner string) (string, string, error) { + existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, grantedOrgID, projectID, resourceOwner) + if err != nil { + return "", "", err + } + if !existingGrant.State.Exists() { + return "", "", zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.Project.Grant.NotFound") + } + return existingGrant.GrantedOrgID, existingGrant.ResourceOwner, nil +} + func (c *Commands) ReactivateProjectGrant(ctx context.Context, projectID, grantID, grantedOrgID, resourceOwner string) (details *domain.ObjectDetails, err error) { if (grantID == "" && grantedOrgID == "") || projectID == "" { return details, zerrors.ThrowInvalidArgument(nil, "PROJECT-p0s4V", "Errors.IDMissing") @@ -302,16 +313,17 @@ func (c *Commands) RemoveProjectGrant(ctx context.Context, projectID, grantID, r ProjectAggregateFromWriteModelWithCTX(ctx, &existingGrant.WriteModel), existingGrant.GrantID, existingGrant.GrantedOrgID, - ), - ) + )) for _, userGrantID := range cascadeUserGrantIDs { - event, _, err := c.removeUserGrant(ctx, userGrantID, "", true) + event, _, err := c.removeUserGrant(ctx, userGrantID, "", true, true, nil) if err != nil { logging.WithFields("id", "COMMAND-3m8sG", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") continue } - events = append(events, event) + if event != nil { + events = append(events, event) + } } pushedEvents, err := c.eventstore.Push(ctx, events...) if err != nil { @@ -348,12 +360,14 @@ func (c *Commands) DeleteProjectGrant(ctx context.Context, projectID, grantID, g ) for _, userGrantID := range cascadeUserGrantIDs { - event, _, err := c.removeUserGrant(ctx, userGrantID, "", true) + event, _, err := c.removeUserGrant(ctx, userGrantID, "", true, true, nil) if err != nil { logging.WithFields("id", "COMMAND-3m8sG", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") continue } - events = append(events, event) + if event != nil { + events = append(events, event) + } } pushedEvents, err := c.eventstore.Push(ctx, events...) if err != nil { diff --git a/internal/command/project_grant_member.go b/internal/command/project_grant_member.go index f7ea887475..611b897c61 100644 --- a/internal/command/project_grant_member.go +++ b/internal/command/project_grant_member.go @@ -2,8 +2,9 @@ package command import ( "context" - "reflect" + "slices" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/project" @@ -11,25 +12,66 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddProjectGrantMember(ctx context.Context, member *domain.ProjectGrantMember) (_ *domain.ProjectGrantMember, err error) { +type AddProjectGrantMember struct { + ResourceOwner string + UserID string + GrantID string + ProjectID string + Roles []string +} + +func (i *AddProjectGrantMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if i.ProjectID == "" || i.GrantID == "" || i.UserID == "" || len(i.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-8fi7G", "Errors.Project.Grant.Member.Invalid") + } + if len(domain.CheckForInvalidRoles(i.Roles, domain.ProjectGrantRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-m9gKK", "Errors.Project.Grant.Member.Invalid") + } + return nil +} + +func (c *Commands) AddProjectGrantMember(ctx context.Context, member *AddProjectGrantMember) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-8fi7G", "Errors.Project.Grant.Member.Invalid") + if err := member.IsValid(c.zitadelRoles); err != nil { + return nil, err } - if len(domain.CheckForInvalidRoles(member.Roles, domain.ProjectGrantRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-m9gKK", "Errors.Project.Grant.Member.Invalid") - } - err = c.checkUserExists(ctx, member.UserID, "") + _, err = c.checkUserExists(ctx, member.UserID, "") if err != nil { return nil, err } - addedMember := NewProjectGrantMemberWriteModel(member.AggregateID, member.UserID, member.GrantID) - projectAgg := ProjectAggregateFromWriteModel(&addedMember.WriteModel) + grantedOrgID, projectGrantResourceOwner, err := c.checkProjectGrantExists(ctx, member.GrantID, "", member.ProjectID, "") + if err != nil { + return nil, err + } + if member.ResourceOwner == "" { + member.ResourceOwner = projectGrantResourceOwner + } + addedMember, err := c.projectGrantMemberWriteModelByID(ctx, member.ProjectID, member.UserID, member.GrantID, member.ResourceOwner) + if err != nil { + return nil, err + } + // TODO: change e2e tests to use correct resourceowner, wrong resource owner is corrected through aggregate + // error if provided resourceowner is not equal to the resourceowner of the project grant + //if projectGrantResourceOwner != addedMember.ResourceOwner { + // return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-0l10S9OmZV", "Errors.Project.Grant.Invalid") + //} + if addedMember.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "PROJECT-37fug", "Errors.AlreadyExists") + } + if err := c.checkPermissionUpdateProjectGrantMember(ctx, grantedOrgID, addedMember.GrantID); err != nil { + return nil, err + } + pushedEvents, err := c.eventstore.Push( ctx, - project.NewProjectGrantMemberAddedEvent(ctx, projectAgg, member.UserID, member.GrantID, member.Roles...)) + project.NewProjectGrantMemberAddedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &addedMember.WriteModel), + member.UserID, + member.GrantID, + member.Roles..., + )) if err != nil { return nil, err } @@ -38,30 +80,58 @@ func (c *Commands) AddProjectGrantMember(ctx context.Context, member *domain.Pro return nil, err } - return memberWriteModelToProjectGrantMember(addedMember), nil + return writeModelToObjectDetails(&addedMember.WriteModel), nil +} + +type ChangeProjectGrantMember struct { + UserID string + GrantID string + ProjectID string + Roles []string +} + +func (i *ChangeProjectGrantMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if i.ProjectID == "" || i.GrantID == "" || i.UserID == "" || len(i.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-109fs", "Errors.Project.Grant.Member.Invalid") + } + if len(domain.CheckForInvalidRoles(i.Roles, domain.ProjectGrantRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-m0sDf", "Errors.Project.Grant.Member.Invalid") + } + return nil } // ChangeProjectGrantMember updates an existing member -func (c *Commands) ChangeProjectGrantMember(ctx context.Context, member *domain.ProjectGrantMember) (*domain.ProjectGrantMember, error) { - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-109fs", "Errors.Project.Member.Invalid") +func (c *Commands) ChangeProjectGrantMember(ctx context.Context, member *ChangeProjectGrantMember) (*domain.ObjectDetails, error) { + if err := member.IsValid(c.zitadelRoles); err != nil { + return nil, err } - if len(domain.CheckForInvalidRoles(member.Roles, domain.ProjectGrantRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-m0sDf", "Errors.Project.Member.Invalid") - } - - existingMember, err := c.projectGrantMemberWriteModelByID(ctx, member.AggregateID, member.UserID, member.GrantID) + existingGrant, err := c.projectGrantWriteModelByID(ctx, member.GrantID, "", member.ProjectID, "") if err != nil { return nil, err } - - if reflect.DeepEqual(existingMember.Roles, member.Roles) { - return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-2n8vx", "Errors.Project.Member.RolesNotChanged") + existingMember, err := c.projectGrantMemberWriteModelByID(ctx, member.ProjectID, member.UserID, member.GrantID, existingGrant.ResourceOwner) + if err != nil { + return nil, err } - projectAgg := ProjectAggregateFromWriteModel(&existingMember.WriteModel) + if !existingMember.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "PROJECT-37fug", "Errors.NotFound") + } + + if err := c.checkPermissionUpdateProjectGrantMember(ctx, existingGrant.GrantedOrgID, existingMember.GrantID); err != nil { + return nil, err + } + if slices.Compare(existingMember.Roles, member.Roles) == 0 { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + pushedEvents, err := c.eventstore.Push( ctx, - project.NewProjectGrantMemberChangedEvent(ctx, projectAgg, member.UserID, member.GrantID, member.Roles...)) + project.NewProjectGrantMemberChangedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel), + member.UserID, + member.GrantID, + member.Roles..., + )) if err != nil { return nil, err } @@ -70,29 +140,43 @@ func (c *Commands) ChangeProjectGrantMember(ctx context.Context, member *domain. return nil, err } - return memberWriteModelToProjectGrantMember(existingMember), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) RemoveProjectGrantMember(ctx context.Context, projectID, userID, grantID string) (*domain.ObjectDetails, error) { if projectID == "" || userID == "" || grantID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-66mHd", "Errors.Project.Member.Invalid") } - m, err := c.projectGrantMemberWriteModelByID(ctx, projectID, userID, grantID) + existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, "", projectID, "") if err != nil { return nil, err } + existingMember, err := c.projectGrantMemberWriteModelByID(ctx, projectID, userID, grantID, existingGrant.ResourceOwner) + if err != nil { + return nil, err + } + if !existingMember.State.Exists() { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + if err := c.checkPermissionDeleteProjectGrantMember(ctx, existingGrant.GrantedOrgID, existingMember.GrantID); err != nil { + return nil, err + } - projectAgg := ProjectAggregateFromWriteModel(&m.WriteModel) - removeEvent := c.removeProjectGrantMember(ctx, projectAgg, userID, grantID, false) + removeEvent := c.removeProjectGrantMember(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel), + userID, + grantID, + false, + ) pushedEvents, err := c.eventstore.Push(ctx, removeEvent) if err != nil { return nil, err } - err = AppendAndReduce(m, pushedEvents...) + err = AppendAndReduce(existingMember, pushedEvents...) if err != nil { return nil, err } - return writeModelToObjectDetails(&m.WriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) removeProjectGrantMember(ctx context.Context, projectAgg *eventstore.Aggregate, userID, grantID string, cascade bool) eventstore.Command { @@ -107,19 +191,15 @@ func (c *Commands) removeProjectGrantMember(ctx context.Context, projectAgg *eve } } -func (c *Commands) projectGrantMemberWriteModelByID(ctx context.Context, projectID, userID, grantID string) (member *ProjectGrantMemberWriteModel, err error) { +func (c *Commands) projectGrantMemberWriteModelByID(ctx context.Context, projectID, userID, grantID, resourceOwner string) (member *ProjectGrantMemberWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel := NewProjectGrantMemberWriteModel(projectID, userID, grantID) + writeModel := NewProjectGrantMemberWriteModel(projectID, userID, grantID, resourceOwner) err = c.eventstore.FilterToQueryReducer(ctx, writeModel) if err != nil { return nil, err } - if writeModel.State == domain.MemberStateUnspecified || writeModel.State == domain.MemberStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "PROJECT-37fug", "Errors.NotFound") - } - return writeModel, nil } diff --git a/internal/command/project_grant_member_model.go b/internal/command/project_grant_member_model.go index 6c089ba1c8..1d9ab02478 100644 --- a/internal/command/project_grant_member_model.go +++ b/internal/command/project_grant_member_model.go @@ -16,10 +16,11 @@ type ProjectGrantMemberWriteModel struct { State domain.MemberState } -func NewProjectGrantMemberWriteModel(projectID, userID, grantID string) *ProjectGrantMemberWriteModel { +func NewProjectGrantMemberWriteModel(projectID, userID, grantID, resourceOwner string) *ProjectGrantMemberWriteModel { return &ProjectGrantMemberWriteModel{ WriteModel: eventstore.WriteModel{ - AggregateID: projectID, + AggregateID: projectID, + ResourceOwner: resourceOwner, }, UserID: userID, GrantID: grantID, @@ -66,6 +67,7 @@ func (wm *ProjectGrantMemberWriteModel) Reduce() error { case *project.GrantMemberAddedEvent: wm.Roles = e.Roles wm.State = domain.MemberStateActive + wm.ResourceOwner = e.Aggregate().ResourceOwner case *project.GrantMemberChangedEvent: wm.Roles = e.Roles case *project.GrantMemberRemovedEvent: @@ -80,7 +82,8 @@ func (wm *ProjectGrantMemberWriteModel) Reduce() error { } func (wm *ProjectGrantMemberWriteModel) Query() *eventstore.SearchQueryBuilder { - return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). AddQuery(). AggregateTypes(project.AggregateType). AggregateIDs(wm.AggregateID). @@ -92,4 +95,5 @@ func (wm *ProjectGrantMemberWriteModel) Query() *eventstore.SearchQueryBuilder { project.GrantRemovedType, project.ProjectRemovedType). Builder() + return query } diff --git a/internal/command/project_grant_member_test.go b/internal/command/project_grant_member_test.go index 189d1b9911..3cd8ecdd82 100644 --- a/internal/command/project_grant_member_test.go +++ b/internal/command/project_grant_member_test.go @@ -10,7 +10,6 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -18,15 +17,15 @@ import ( func TestCommandSide_AddProjectGrantMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - member *domain.ProjectGrantMember + member *AddProjectGrantMember } type res struct { - want *domain.ProjectGrantMember + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -38,16 +37,12 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, + member: &AddProjectGrantMember{ + ProjectID: "project1", }, }, res: res{ @@ -57,19 +52,15 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &AddProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ @@ -79,10 +70,10 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -90,14 +81,11 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &AddProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ @@ -107,8 +95,7 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { { name: "member add uniqueconstraint err, already exists", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -125,15 +112,28 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + ), + ), + ), + ), + expectFilter(), expectPushFailed(zerrors.ThrowAlreadyExists(nil, "ERROR", "internal"), project.NewProjectGrantMemberAddedEvent(context.Background(), - &project.NewAggregate("project1", "").Aggregate, + &project.NewAggregate("project1", "org1").Aggregate, "user1", "projectgrant1", []string{"PROJECT_GRANT_OWNER"}..., ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -141,14 +141,11 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &AddProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ @@ -158,8 +155,7 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { { name: "member add, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -176,15 +172,28 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + ), + ), + ), + ), + expectFilter(), expectPush( project.NewProjectGrantMemberAddedEvent(context.Background(), - &project.NewAggregate("project1", "").Aggregate, + &project.NewAggregate("project1", "org1").Aggregate, "user1", "projectgrant1", []string{"PROJECT_GRANT_OWNER"}..., ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -192,35 +201,81 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - GrantID: "projectgrant1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &AddProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ - want: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "project1", }, }, }, + { + name: "member add, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + ), + ), + ), + ), + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: "PROJECT_GRANT_OWNER", + }, + }, + }, + args: args{ + member: &AddProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.AddProjectGrantMember(tt.args.ctx, tt.args.member) + got, err := r.AddProjectGrantMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -236,15 +291,15 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - member *domain.ProjectGrantMember + member *ChangeProjectGrantMember } type res struct { - want *domain.ProjectGrantMember + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -256,16 +311,12 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, + member: &ChangeProjectGrantMember{ + ProjectID: "project1", }, }, res: res{ @@ -275,19 +326,15 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &ChangeProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, }, res: res{ @@ -297,10 +344,20 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { { name: "member not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -308,14 +365,11 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &ChangeProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ @@ -325,8 +379,17 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { { name: "member not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), expectFilter( eventFromEventPusher( project.NewProjectGrantMemberAddedEvent(context.Background(), @@ -338,6 +401,7 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -345,25 +409,33 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &ChangeProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "member change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), expectFilter( eventFromEventPusher( project.NewProjectGrantMemberAddedEvent(context.Background(), @@ -383,6 +455,7 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -393,36 +466,75 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER", "PROJECT_GRANT_VIEWER"}, + member: &ChangeProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER", "PROJECT_GRANT_VIEWER"}, }, }, res: res{ - want: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER", "PROJECT_GRANT_VIEWER"}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, + { + name: "member change, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + project.NewProjectGrantMemberAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "user1", + "projectgrant1", + []string{"PROJECT_GRANT_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: "PROJECT_GRANT_OWNER", + }, + { + Role: "PROJECT_GRANT_VIEWER", + }, + }, + }, + args: args{ + member: &ChangeProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER", "PROJECT_GRANT_VIEWER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.ChangeProjectGrantMember(tt.args.ctx, tt.args.member) + got, err := r.ChangeProjectGrantMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -430,7 +542,7 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -438,7 +550,8 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -459,9 +572,8 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { { name: "invalid member projectid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -476,9 +588,8 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { { name: "invalid member userid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -493,9 +604,8 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { { name: "invalid member grantid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -508,12 +618,22 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { }, }, { - name: "member not existing, not found err", + name: "member not existing, not found ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -522,14 +642,25 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { grantID: "projectgrant1", }, res: res{ - err: zerrors.IsNotFound, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "member remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), expectFilter( eventFromEventPusher( project.NewProjectGrantMemberAddedEvent(context.Background(), @@ -548,6 +679,7 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -561,11 +693,49 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { }, }, }, + { + name: "member remove, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + project.NewProjectGrantMemberAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "user1", + "projectgrant1", + []string{"PROJECT_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + userID: "user1", + grantID: "projectgrant1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.RemoveProjectGrantMember(tt.args.ctx, tt.args.projectID, tt.args.userID, tt.args.grantID) if tt.res.err == nil { diff --git a/internal/command/project_member.go b/internal/command/project_member.go index a2e4fae553..1bbd358490 100644 --- a/internal/command/project_member.go +++ b/internal/command/project_member.go @@ -2,8 +2,9 @@ package command import ( "context" - "reflect" + "slices" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/project" @@ -11,18 +12,64 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddProjectMember(ctx context.Context, member *domain.Member, resourceOwner string) (_ *domain.Member, err error) { +type AddProjectMember struct { + ResourceOwner string + ProjectID string + UserID string + Roles []string +} + +func (i *AddProjectMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if i.ProjectID == "" || i.UserID == "" || len(i.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-W8m4l", "Errors.Project.Member.Invalid") + } + if len(domain.CheckForInvalidRoles(i.Roles, domain.ProjectRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-3m9ds", "Errors.Project.Member.Invalid") + } + return nil +} + +func (c *Commands) AddProjectMember(ctx context.Context, member *AddProjectMember) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - addedMember := NewProjectMemberWriteModel(member.AggregateID, member.UserID, resourceOwner) - projectAgg := ProjectAggregateFromWriteModel(&addedMember.WriteModel) - event, err := c.addProjectMember(ctx, projectAgg, addedMember, member) + if err := member.IsValid(c.zitadelRoles); err != nil { + return nil, err + } + _, err = c.checkUserExists(ctx, member.UserID, "") if err != nil { return nil, err } + projectResourceOwner, err := c.checkProjectExists(ctx, member.ProjectID, member.ResourceOwner) + if err != nil { + return nil, err + } + // resourceowner of the member if not provided is the resourceowner of the project + if member.ResourceOwner == "" { + member.ResourceOwner = projectResourceOwner + } + addedMember, err := c.projectMemberWriteModelByID(ctx, member.ProjectID, member.UserID, member.ResourceOwner) + if err != nil { + return nil, err + } + // error if provided resourceowner is not equal to the resourceowner of the project + if projectResourceOwner != addedMember.ResourceOwner { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-0l10S9OmZV", "Errors.Project.Member.Invalid") + } + if err := c.checkPermissionUpdateProjectMember(ctx, addedMember.ResourceOwner, addedMember.AggregateID); err != nil { + return nil, err + } + if addedMember.State.Exists() { + return nil, zerrors.ThrowAlreadyExists(nil, "PROJECT-PtXi1", "Errors.Project.Member.AlreadyExists") + } - pushedEvents, err := c.eventstore.Push(ctx, event) + pushedEvents, err := c.eventstore.Push(ctx, + project.NewProjectMemberAddedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &addedMember.WriteModel), + member.UserID, + member.Roles..., + ), + ) if err != nil { return nil, err } @@ -31,53 +78,46 @@ func (c *Commands) AddProjectMember(ctx context.Context, member *domain.Member, return nil, err } - return memberWriteModelToMember(&addedMember.MemberWriteModel), nil + return writeModelToObjectDetails(&addedMember.WriteModel), nil } -func (c *Commands) addProjectMember(ctx context.Context, projectAgg *eventstore.Aggregate, addedMember *ProjectMemberWriteModel, member *domain.Member) (_ eventstore.Command, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() +type ChangeProjectMember struct { + ResourceOwner string + ProjectID string + UserID string + Roles []string +} - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-W8m4l", "Errors.Project.Member.Invalid") +func (i *ChangeProjectMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if i.ProjectID == "" || i.UserID == "" || len(i.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-LiaZi", "Errors.Project.Member.Invalid") } - if len(domain.CheckForInvalidRoles(member.Roles, domain.ProjectRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-3m9ds", "Errors.Project.Member.Invalid") + if len(domain.CheckForInvalidRoles(i.Roles, domain.ProjectRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-3m9d", "Errors.Project.Member.Invalid") } - - err = c.checkUserExists(ctx, addedMember.UserID, "") - if err != nil { - return nil, err - } - err = c.eventstore.FilterToQueryReducer(ctx, addedMember) - if err != nil { - return nil, err - } - if addedMember.State == domain.MemberStateActive { - return nil, zerrors.ThrowAlreadyExists(nil, "PROJECT-PtXi1", "Errors.Project.Member.AlreadyExists") - } - - return project.NewProjectMemberAddedEvent(ctx, projectAgg, member.UserID, member.Roles...), nil + return nil } // ChangeProjectMember updates an existing member -func (c *Commands) ChangeProjectMember(ctx context.Context, member *domain.Member, resourceOwner string) (*domain.Member, error) { - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-LiaZi", "Errors.Project.Member.Invalid") - } - if len(domain.CheckForInvalidRoles(member.Roles, domain.ProjectRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-3m9d", "Errors.Project.Member.Invalid") - } - - existingMember, err := c.projectMemberWriteModelByID(ctx, member.AggregateID, member.UserID, resourceOwner) - if err != nil { +func (c *Commands) ChangeProjectMember(ctx context.Context, member *ChangeProjectMember) (*domain.ObjectDetails, error) { + if err := member.IsValid(c.zitadelRoles); err != nil { return nil, err } - if reflect.DeepEqual(existingMember.Roles, member.Roles) { - return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-LiaZi", "Errors.Project.Member.RolesNotChanged") + existingMember, err := c.projectMemberWriteModelByID(ctx, member.ProjectID, member.UserID, member.ResourceOwner) + if err != nil { + return nil, err } - projectAgg := ProjectAggregateFromWriteModel(&existingMember.MemberWriteModel.WriteModel) + if !existingMember.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.NotFound") + } + if err := c.checkPermissionUpdateProjectMember(ctx, existingMember.ResourceOwner, existingMember.AggregateID); err != nil { + return nil, err + } + if slices.Compare(existingMember.Roles, member.Roles) == 0 { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + projectAgg := ProjectAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, project.NewProjectMemberChangedEvent(ctx, projectAgg, member.UserID, member.Roles...)) if err != nil { return nil, err @@ -88,33 +128,35 @@ func (c *Commands) ChangeProjectMember(ctx context.Context, member *domain.Membe return nil, err } - return memberWriteModelToMember(&existingMember.MemberWriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) RemoveProjectMember(ctx context.Context, projectID, userID, resourceOwner string) (*domain.ObjectDetails, error) { if projectID == "" || userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-66mHd", "Errors.Project.Member.Invalid") } - m, err := c.projectMemberWriteModelByID(ctx, projectID, userID, resourceOwner) - if err != nil && !zerrors.IsNotFound(err) { + existingMember, err := c.projectMemberWriteModelByID(ctx, projectID, userID, resourceOwner) + if err != nil { return nil, err } - if zerrors.IsNotFound(err) { - // empty response because we have no data that match the request - return &domain.ObjectDetails{}, nil + if !existingMember.State.Exists() { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + if err := c.checkPermissionDeleteProjectMember(ctx, existingMember.ResourceOwner, existingMember.AggregateID); err != nil { + return nil, err } - projectAgg := ProjectAggregateFromWriteModel(&m.MemberWriteModel.WriteModel) + projectAgg := ProjectAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel) removeEvent := c.removeProjectMember(ctx, projectAgg, userID, false) pushedEvents, err := c.eventstore.Push(ctx, removeEvent) if err != nil { return nil, err } - err = AppendAndReduce(m, pushedEvents...) + err = AppendAndReduce(existingMember, pushedEvents...) if err != nil { return nil, err } - return writeModelToObjectDetails(&m.WriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) removeProjectMember(ctx context.Context, projectAgg *eventstore.Aggregate, userID string, cascade bool) eventstore.Command { @@ -138,9 +180,5 @@ func (c *Commands) projectMemberWriteModelByID(ctx context.Context, projectID, u return nil, err } - if writeModel.State == domain.MemberStateUnspecified || writeModel.State == domain.MemberStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.NotFound") - } - return writeModel, nil } diff --git a/internal/command/project_member_test.go b/internal/command/project_member_test.go index 88b52f63f8..1bd06e0b99 100644 --- a/internal/command/project_member_test.go +++ b/internal/command/project_member_test.go @@ -10,7 +10,6 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -18,16 +17,15 @@ import ( func TestCommandSide_AddProjectMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - member *domain.Member - resourceOwner string + member *AddProjectMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -39,18 +37,14 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -59,20 +53,16 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -81,10 +71,10 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -92,15 +82,12 @@ func TestCommandSide_AddProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -109,8 +96,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "member already exists, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -127,6 +113,19 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + false, + false, + false, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), expectFilter( eventFromEventPusher( project.NewProjectMemberAddedEvent(context.Background(), @@ -136,6 +135,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -143,15 +143,12 @@ func TestCommandSide_AddProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -160,8 +157,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "member add uniqueconstraint err, already exists", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -178,6 +174,19 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + false, + false, + false, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), expectFilter(), expectPushFailed(zerrors.ThrowAlreadyExists(nil, "ERROR", "internal"), project.NewProjectMemberAddedEvent(context.Background(), @@ -187,6 +196,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -194,15 +204,12 @@ func TestCommandSide_AddProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -211,8 +218,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "member add, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -229,6 +235,19 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + false, + false, + false, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), expectFilter(), expectPush( project.NewProjectMemberAddedEvent(context.Background(), @@ -238,6 +257,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -245,35 +265,81 @@ func TestCommandSide_AddProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{domain.RoleProjectOwner}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, + }, { + name: "member add, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + false, + false, + false, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: domain.RoleProjectOwner, + }, + }, + }, + args: args{ + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.AddProjectMember(tt.args.ctx, tt.args.member, tt.args.resourceOwner) + got, err := r.AddProjectMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -281,7 +347,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -289,16 +355,15 @@ func TestCommandSide_AddProjectMember(t *testing.T) { func TestCommandSide_ChangeProjectMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - member *domain.Member - resourceOwner string + member *ChangeProjectMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -310,18 +375,14 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -330,20 +391,16 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -352,10 +409,10 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { { name: "member not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -363,15 +420,12 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsNotFound, @@ -380,8 +434,7 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { { name: "member not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectMemberAddedEvent(context.Background(), @@ -392,6 +445,7 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -399,25 +453,23 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "member change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectMemberAddedEvent(context.Background(), @@ -435,6 +487,7 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -445,35 +498,64 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER", "PROJECT_VIEWER"}, + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER", "PROJECT_VIEWER"}, }, - resourceOwner: "org1", }, res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{domain.RoleProjectOwner, "PROJECT_VIEWER"}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, + { + name: "member change, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectMemberAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "user1", + []string{"PROJECT_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: domain.RoleProjectOwner, + }, + { + Role: "PROJECT_VIEWER", + }, + }, + }, + args: args{ + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER", "PROJECT_VIEWER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.ChangeProjectMember(tt.args.ctx, tt.args.member, tt.args.resourceOwner) + got, err := r.ChangeProjectMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -481,7 +563,7 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -489,10 +571,10 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { func TestCommandSide_RemoveProjectMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context projectID string userID string resourceOwner string @@ -510,12 +592,10 @@ func TestCommandSide_RemoveProjectMember(t *testing.T) { { name: "invalid member projectid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), projectID: "", userID: "user1", resourceOwner: "org1", @@ -527,12 +607,10 @@ func TestCommandSide_RemoveProjectMember(t *testing.T) { { name: "invalid member userid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), projectID: "project1", userID: "", resourceOwner: "org1", @@ -544,26 +622,26 @@ func TestCommandSide_RemoveProjectMember(t *testing.T) { { name: "member not existing, empty object details result", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), projectID: "project1", userID: "user1", resourceOwner: "org1", }, res: res{ - want: &domain.ObjectDetails{}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "member remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectMemberAddedEvent(context.Background(), @@ -580,9 +658,9 @@ func TestCommandSide_RemoveProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), projectID: "project1", userID: "user1", resourceOwner: "org1", @@ -593,13 +671,39 @@ func TestCommandSide_RemoveProjectMember(t *testing.T) { }, }, }, + { + name: "member remove, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectMemberAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "user1", + []string{"PROJECT_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + projectID: "project1", + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } - got, err := r.RemoveProjectMember(tt.args.ctx, tt.args.projectID, tt.args.userID, tt.args.resourceOwner) + got, err := r.RemoveProjectMember(context.Background(), tt.args.projectID, tt.args.userID, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/project_model.go b/internal/command/project_model.go index cabceb8500..4c9496b3ad 100644 --- a/internal/command/project_model.go +++ b/internal/command/project_model.go @@ -2,6 +2,7 @@ package command import ( "context" + "slices" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -120,7 +121,7 @@ func (wm *ProjectWriteModel) NewChangedEvent( } func isProjectStateExists(state domain.ProjectState) bool { - return !hasProjectState(state, domain.ProjectStateRemoved, domain.ProjectStateUnspecified) + return !slices.Contains([]domain.ProjectState{domain.ProjectStateRemoved, domain.ProjectStateUnspecified}, state) } func ProjectAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { @@ -130,12 +131,3 @@ func ProjectAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggre func ProjectAggregateFromWriteModelWithCTX(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { return project.AggregateFromWriteModel(ctx, wm) } - -func hasProjectState(check domain.ProjectState, states ...domain.ProjectState) bool { - for _, state := range states { - if check == state { - return true - } - } - return false -} diff --git a/internal/command/system_features.go b/internal/command/system_features.go index b317ea93bb..c2d4f9f9e7 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -10,23 +10,19 @@ import ( ) type SystemFeatures struct { - LoginDefaultOrg *bool - TriggerIntrospectionProjections *bool - LegacyIntrospection *bool - TokenExchange *bool - UserSchema *bool - ImprovedPerformance []feature.ImprovedPerformanceType - OIDCSingleV1SessionTermination *bool - DisableUserTokenEvent *bool - EnableBackChannelLogout *bool - LoginV2 *feature.LoginV2 - PermissionCheckV2 *bool + LoginDefaultOrg *bool + TokenExchange *bool + UserSchema *bool + ImprovedPerformance []feature.ImprovedPerformanceType + OIDCSingleV1SessionTermination *bool + DisableUserTokenEvent *bool + EnableBackChannelLogout *bool + LoginV2 *feature.LoginV2 + PermissionCheckV2 *bool } func (m *SystemFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && - m.TriggerIntrospectionProjections == nil && - m.LegacyIntrospection == nil && m.UserSchema == nil && m.TokenExchange == nil && // nil check to allow unset improvements diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index 28e56f8bd4..212d00e6ce 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -60,8 +60,6 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { EventTypes( feature_v2.SystemResetEventType, feature_v2.SystemLoginDefaultOrgEventType, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, - feature_v2.SystemLegacyIntrospectionEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, feature_v2.SystemImprovedPerformanceEventType, @@ -85,12 +83,6 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) { case feature.KeyLoginDefaultOrg: v := value.(bool) features.LoginDefaultOrg = &v - case feature.KeyTriggerIntrospectionProjections: - v := value.(bool) - features.TriggerIntrospectionProjections = &v - case feature.KeyLegacyIntrospection: - v := value.(bool) - features.LegacyIntrospection = &v case feature.KeyUserSchema: v := value.(bool) features.UserSchema = &v @@ -120,8 +112,6 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe aggregate := feature_v2.NewAggregate(wm.AggregateID, wm.ResourceOwner) cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.SystemLoginDefaultOrgEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.SystemTriggerIntrospectionProjectionsEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.SystemLegacyIntrospectionEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.SystemUserSchemaEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.SystemTokenExchangeEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.SystemImprovedPerformanceEventType) diff --git a/internal/command/system_features_test.go b/internal/command/system_features_test.go index b1b5207b8c..2defd23d5e 100644 --- a/internal/command/system_features_test.go +++ b/internal/command/system_features_test.go @@ -63,42 +63,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ResourceOwner: "SYSTEM", }, }, - { - name: "set TriggerIntrospectionProjections", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - ), - ), - ), - args: args{context.Background(), &SystemFeatures{ - TriggerIntrospectionProjections: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "SYSTEM", - }, - }, - { - name: "set LegacyIntrospection", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, - ), - ), - ), - args: args{context.Background(), &SystemFeatures{ - LegacyIntrospection: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "SYSTEM", - }, - }, { name: "set UserSchema", eventstore: expectEventstore( @@ -124,12 +88,12 @@ func TestCommands_SetSystemFeatures(t *testing.T) { expectPushFailed(io.ErrClosedPipe, feature_v2.NewSetEvent[bool]( context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, + feature_v2.SystemEnableBackChannelLogout, true, ), ), ), args: args{context.Background(), &SystemFeatures{ - LegacyIntrospection: gu.Ptr(true), + EnableBackChannelLogout: gu.Ptr(true), }}, wantErr: io.ErrClosedPipe, }, @@ -142,14 +106,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, - ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, - ), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, true, @@ -161,11 +117,9 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ), ), args: args{context.Background(), &SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), - UserSchema: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(true), }}, want: &domain.ObjectDetails{ ResourceOwner: "SYSTEM", @@ -180,10 +134,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, - )), eventFromEventPusher(feature_v2.NewResetEvent( context.Background(), aggregate, feature_v2.SystemResetEventType, @@ -192,20 +142,12 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, - )), ), expectPush( feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, - ), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, true, @@ -217,11 +159,9 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ), ), args: args{context.Background(), &SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), - UserSchema: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(false), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(false), }}, want: &domain.ObjectDetails{ ResourceOwner: "SYSTEM", diff --git a/internal/command/user.go b/internal/command/user.go index 0db4fda328..d834169f8a 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -194,7 +194,7 @@ func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string, events = append(events, user.NewUserRemovedEvent(ctx, userAgg, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain)) for _, grantID := range cascadingGrantIDs { - removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true) + removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true, false, nil) if err != nil { logging.WithFields("usergrantid", grantID).WithError(err).Warn("could not cascade remove role on user grant") continue @@ -327,18 +327,18 @@ func (c *Commands) UserDomainClaimedSent(ctx context.Context, orgID, userID stri return err } -func (c *Commands) checkUserExists(ctx context.Context, userID, resourceOwner string) (err error) { +func (c *Commands) checkUserExists(ctx context.Context, userID, resourceOwner string) (_ string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() existingUser, err := c.userWriteModelByID(ctx, userID, resourceOwner) if err != nil { - return err + return "", err } if !isUserStateExists(existingUser.UserState) { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-uXHNj", "Errors.User.NotFound") + return "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-uXHNj", "Errors.User.NotFound") } - return nil + return existingUser.ResourceOwner, nil } func (c *Commands) userWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *UserWriteModel, err error) { diff --git a/internal/command/user_grant.go b/internal/command/user_grant.go index 6bb4a20b0a..2b0ff4cf18 100644 --- a/internal/command/user_grant.go +++ b/internal/command/user_grant.go @@ -2,7 +2,7 @@ package command import ( "context" - "reflect" + "slices" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" @@ -15,11 +15,14 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddUserGrant(ctx context.Context, usergrant *domain.UserGrant, resourceOwner string) (_ *domain.UserGrant, err error) { +// AddUserGrant authorizes a user for a project with the given role keys. +// The project must be owned by or granted to the resourceOwner. +// If the resourceOwner is nil, the project must be owned by the project that belongs to usergrant.ProjectID. +func (c *Commands) AddUserGrant(ctx context.Context, usergrant *domain.UserGrant, check UserGrantPermissionCheck) (_ *domain.UserGrant, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - event, addedUserGrant, err := c.addUserGrant(ctx, usergrant, resourceOwner) + event, addedUserGrant, err := c.addUserGrant(ctx, usergrant, check) if err != nil { return nil, err } @@ -35,11 +38,11 @@ func (c *Commands) AddUserGrant(ctx context.Context, usergrant *domain.UserGrant return userGrantWriteModelToUserGrant(addedUserGrant), nil } -func (c *Commands) addUserGrant(ctx context.Context, userGrant *domain.UserGrant, resourceOwner string) (command eventstore.Command, _ *UserGrantWriteModel, err error) { +func (c *Commands) addUserGrant(ctx context.Context, userGrant *domain.UserGrant, check UserGrantPermissionCheck) (command eventstore.Command, _ *UserGrantWriteModel, err error) { if !userGrant.IsValid() { return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-kVfMa", "Errors.UserGrant.Invalid") } - err = c.checkUserGrantPreCondition(ctx, userGrant, resourceOwner) + err = c.checkUserGrantPreCondition(ctx, userGrant, check) if err != nil { return nil, nil, err } @@ -48,7 +51,7 @@ func (c *Commands) addUserGrant(ctx context.Context, userGrant *domain.UserGrant return nil, nil, err } - addedUserGrant := NewUserGrantWriteModel(userGrant.AggregateID, resourceOwner) + addedUserGrant := NewUserGrantWriteModel(userGrant.AggregateID, userGrant.ResourceOwner) userGrantAgg := UserGrantAggregateFromWriteModel(&addedUserGrant.WriteModel) command = usergrant.NewUserGrantAddedEvent( ctx, @@ -61,54 +64,51 @@ func (c *Commands) addUserGrant(ctx context.Context, userGrant *domain.UserGrant return command, addedUserGrant, nil } -func (c *Commands) ChangeUserGrant(ctx context.Context, userGrant *domain.UserGrant, resourceOwner string) (_ *domain.UserGrant, err error) { - event, changedUserGrant, err := c.changeUserGrant(ctx, userGrant, resourceOwner, false) +func (c *Commands) ChangeUserGrant(ctx context.Context, userGrant *domain.UserGrant, cascade, ignoreUnchanged bool, check UserGrantPermissionCheck) (_ *domain.UserGrant, err error) { + if userGrant.AggregateID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-3M0sd", "Errors.UserGrant.Invalid") + } + existingUserGrant, err := c.userGrantWriteModelByID(ctx, userGrant.AggregateID, "") if err != nil { return nil, err } + if existingUserGrant.State == domain.UserGrantStateUnspecified || existingUserGrant.State == domain.UserGrantStateRemoved { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.UserGrant.NotFound") + } + + grantUnchanged := slices.Equal(existingUserGrant.RoleKeys, userGrant.RoleKeys) + if grantUnchanged { + if ignoreUnchanged { + return userGrantWriteModelToUserGrant(existingUserGrant), nil + } + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Rs8fy", "Errors.UserGrant.NotChanged") + } + userGrant.UserID = existingUserGrant.UserID + userGrant.ProjectID = existingUserGrant.ProjectID + userGrant.ProjectGrantID = existingUserGrant.ProjectGrantID + userGrant.ResourceOwner = existingUserGrant.ResourceOwner + + err = c.checkUserGrantPreCondition(ctx, userGrant, check) + if err != nil { + return nil, err + } + + changedUserGrant := NewUserGrantWriteModel(userGrant.AggregateID, userGrant.ResourceOwner) + userGrantAgg := UserGrantAggregateFromWriteModel(&changedUserGrant.WriteModel) + + var event eventstore.Command = usergrant.NewUserGrantChangedEvent(ctx, userGrantAgg, existingUserGrant.UserID, userGrant.RoleKeys) + if cascade { + event = usergrant.NewUserGrantCascadeChangedEvent(ctx, userGrantAgg, userGrant.RoleKeys) + } pushedEvents, err := c.eventstore.Push(ctx, event) if err != nil { return nil, err } - err = AppendAndReduce(changedUserGrant, pushedEvents...) + err = AppendAndReduce(existingUserGrant, pushedEvents...) if err != nil { return nil, err } - return userGrantWriteModelToUserGrant(changedUserGrant), nil -} - -func (c *Commands) changeUserGrant(ctx context.Context, userGrant *domain.UserGrant, resourceOwner string, cascade bool) (_ eventstore.Command, _ *UserGrantWriteModel, err error) { - if userGrant.AggregateID == "" { - return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-3M0sd", "Errors.UserGrant.Invalid") - } - existingUserGrant, err := c.userGrantWriteModelByID(ctx, userGrant.AggregateID, userGrant.ResourceOwner) - if err != nil { - return nil, nil, err - } - err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) - if err != nil { - return nil, nil, err - } - if existingUserGrant.State == domain.UserGrantStateUnspecified || existingUserGrant.State == domain.UserGrantStateRemoved { - return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.UserGrant.NotFound") - } - if reflect.DeepEqual(existingUserGrant.RoleKeys, userGrant.RoleKeys) { - return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Rs8fy", "Errors.UserGrant.NotChanged") - } - userGrant.ProjectID = existingUserGrant.ProjectID - userGrant.ProjectGrantID = existingUserGrant.ProjectGrantID - err = c.checkUserGrantPreCondition(ctx, userGrant, resourceOwner) - if err != nil { - return nil, nil, err - } - - changedUserGrant := NewUserGrantWriteModel(userGrant.AggregateID, resourceOwner) - userGrantAgg := UserGrantAggregateFromWriteModel(&changedUserGrant.WriteModel) - - if cascade { - return usergrant.NewUserGrantCascadeChangedEvent(ctx, userGrantAgg, userGrant.RoleKeys), existingUserGrant, nil - } - return usergrant.NewUserGrantChangedEvent(ctx, userGrantAgg, existingUserGrant.UserID, userGrant.RoleKeys), existingUserGrant, nil + return userGrantWriteModelToUserGrant(existingUserGrant), nil } func (c *Commands) removeRoleFromUserGrant(ctx context.Context, userGrantID string, roleKeys []string, cascade bool) (_ eventstore.Command, err error) { @@ -144,8 +144,8 @@ func (c *Commands) removeRoleFromUserGrant(ctx context.Context, userGrantID stri return usergrant.NewUserGrantChangedEvent(ctx, userGrantAgg, existingUserGrant.UserID, existingUserGrant.RoleKeys), nil } -func (c *Commands) DeactivateUserGrant(ctx context.Context, grantID, resourceOwner string) (objectDetails *domain.ObjectDetails, err error) { - if grantID == "" || resourceOwner == "" { +func (c *Commands) DeactivateUserGrant(ctx context.Context, grantID string, resourceOwner string, check UserGrantPermissionCheck) (objectDetails *domain.ObjectDetails, err error) { + if grantID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-N2OhG", "Errors.UserGrant.IDMissing") } @@ -157,14 +157,17 @@ func (c *Commands) DeactivateUserGrant(ctx context.Context, grantID, resourceOwn return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.UserGrant.NotFound") } if existingUserGrant.State != domain.UserGrantStateActive { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-1S9gx", "Errors.UserGrant.NotActive") + return writeModelToObjectDetails(&existingUserGrant.WriteModel), nil + } + if check != nil { + err = check(existingUserGrant.ProjectID, existingUserGrant.ProjectGrantID)(existingUserGrant.ResourceOwner, "") + } else { + err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) } - err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) if err != nil { return nil, err } - - deactivateUserGrant := NewUserGrantWriteModel(grantID, resourceOwner) + deactivateUserGrant := NewUserGrantWriteModel(grantID, existingUserGrant.ResourceOwner) userGrantAgg := UserGrantAggregateFromWriteModel(&deactivateUserGrant.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, usergrant.NewUserGrantDeactivatedEvent(ctx, userGrantAgg)) if err != nil { @@ -177,8 +180,8 @@ func (c *Commands) DeactivateUserGrant(ctx context.Context, grantID, resourceOwn return writeModelToObjectDetails(&existingUserGrant.WriteModel), nil } -func (c *Commands) ReactivateUserGrant(ctx context.Context, grantID, resourceOwner string) (objectDetails *domain.ObjectDetails, err error) { - if grantID == "" || resourceOwner == "" { +func (c *Commands) ReactivateUserGrant(ctx context.Context, grantID string, resourceOwner string, check UserGrantPermissionCheck) (objectDetails *domain.ObjectDetails, err error) { + if grantID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Qxy8v", "Errors.UserGrant.IDMissing") } @@ -190,13 +193,17 @@ func (c *Commands) ReactivateUserGrant(ctx context.Context, grantID, resourceOwn return nil, zerrors.ThrowNotFound(nil, "COMMAND-Lp0gs", "Errors.UserGrant.NotFound") } if existingUserGrant.State != domain.UserGrantStateInactive { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-1ML0v", "Errors.UserGrant.NotInactive") + return writeModelToObjectDetails(&existingUserGrant.WriteModel), nil + } + if check != nil { + err = check(existingUserGrant.ProjectID, existingUserGrant.ProjectGrantID)(existingUserGrant.ResourceOwner, "") + } else { + err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) } - err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) if err != nil { return nil, err } - deactivateUserGrant := NewUserGrantWriteModel(grantID, resourceOwner) + deactivateUserGrant := NewUserGrantWriteModel(grantID, existingUserGrant.ResourceOwner) userGrantAgg := UserGrantAggregateFromWriteModel(&deactivateUserGrant.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, usergrant.NewUserGrantReactivatedEvent(ctx, userGrantAgg)) if err != nil { @@ -209,12 +216,14 @@ func (c *Commands) ReactivateUserGrant(ctx context.Context, grantID, resourceOwn return writeModelToObjectDetails(&existingUserGrant.WriteModel), nil } -func (c *Commands) RemoveUserGrant(ctx context.Context, grantID, resourceOwner string) (objectDetails *domain.ObjectDetails, err error) { - event, existingUserGrant, err := c.removeUserGrant(ctx, grantID, resourceOwner, false) +func (c *Commands) RemoveUserGrant(ctx context.Context, grantID string, resourceOwner string, ignoreNotFound bool, check UserGrantPermissionCheck) (objectDetails *domain.ObjectDetails, err error) { + event, existingUserGrant, err := c.removeUserGrant(ctx, grantID, resourceOwner, false, ignoreNotFound, check) if err != nil { return nil, err } - + if event == nil { + return writeModelToObjectDetails(&existingUserGrant.WriteModel), nil + } pushedEvents, err := c.eventstore.Push(ctx, event) if err != nil { return nil, err @@ -232,7 +241,7 @@ func (c *Commands) BulkRemoveUserGrant(ctx context.Context, grantIDs []string, r } events := make([]eventstore.Command, len(grantIDs)) for i, grantID := range grantIDs { - event, _, err := c.removeUserGrant(ctx, grantID, resourceOwner, false) + event, _, err := c.removeUserGrant(ctx, grantID, resourceOwner, false, false, nil) if err != nil { return err } @@ -242,7 +251,7 @@ func (c *Commands) BulkRemoveUserGrant(ctx context.Context, grantIDs []string, r return err } -func (c *Commands) removeUserGrant(ctx context.Context, grantID, resourceOwner string, cascade bool) (_ eventstore.Command, writeModel *UserGrantWriteModel, err error) { +func (c *Commands) removeUserGrant(ctx context.Context, grantID string, resourceOwner string, cascade, ignoreNotFound bool, check UserGrantPermissionCheck) (_ eventstore.Command, writeModel *UserGrantWriteModel, err error) { if grantID == "" { return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-J9sc5", "Errors.UserGrant.IDMissing") } @@ -252,15 +261,22 @@ func (c *Commands) removeUserGrant(ctx context.Context, grantID, resourceOwner s return nil, nil, err } if existingUserGrant.State == domain.UserGrantStateUnspecified || existingUserGrant.State == domain.UserGrantStateRemoved { + if ignoreNotFound { + return nil, existingUserGrant, nil + } return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-1My0t", "Errors.UserGrant.NotFound") } - if !cascade { + if !cascade && check == nil { err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) if err != nil { return nil, nil, err } } - + if check != nil { + if err = check(existingUserGrant.ProjectID, existingUserGrant.ProjectGrantID)(existingUserGrant.ResourceOwner, ""); err != nil { + return nil, nil, err + } + } removeUserGrant := NewUserGrantWriteModel(grantID, existingUserGrant.ResourceOwner) userGrantAgg := UserGrantAggregateFromWriteModel(&removeUserGrant.WriteModel) if cascade { @@ -279,7 +295,7 @@ func (c *Commands) removeUserGrant(ctx context.Context, grantID, resourceOwner s existingUserGrant.ProjectGrantID), existingUserGrant, nil } -func (c *Commands) userGrantWriteModelByID(ctx context.Context, userGrantID, resourceOwner string) (writeModel *UserGrantWriteModel, err error) { +func (c *Commands) userGrantWriteModelByID(ctx context.Context, userGrantID string, resourceOwner string) (writeModel *UserGrantWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -291,31 +307,46 @@ func (c *Commands) userGrantWriteModelByID(ctx context.Context, userGrantID, res return writeModel, nil } -func (c *Commands) checkUserGrantPreCondition(ctx context.Context, usergrant *domain.UserGrant, resourceOwner string) (err error) { +func (c *Commands) checkUserGrantPreCondition(ctx context.Context, usergrant *domain.UserGrant, check UserGrantPermissionCheck) (err error) { if !authz.GetFeatures(ctx).ShouldUseImprovedPerformance(feature.ImprovedPerformanceTypeUserGrant) { - return c.checkUserGrantPreConditionOld(ctx, usergrant, resourceOwner) + return c.checkUserGrantPreConditionOld(ctx, usergrant, check) } ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err := c.checkUserExists(ctx, usergrant.UserID, ""); err != nil { + if _, err := c.checkUserExists(ctx, usergrant.UserID, ""); err != nil { return err } - existingRoleKeys, err := c.searchUserGrantPreConditionState(ctx, usergrant, resourceOwner) + if usergrant.ProjectGrantID != "" || usergrant.ResourceOwner == "" { + projectOwner, grantID, err := c.searchProjectOwnerAndGrantID(ctx, usergrant.ProjectID, "") + if err != nil { + return err + } + if usergrant.ResourceOwner == "" { + usergrant.ResourceOwner = projectOwner + } + if usergrant.ProjectGrantID == "" { + usergrant.ProjectGrantID = grantID + } + } + existingRoleKeys, err := c.searchUserGrantPreConditionState(ctx, usergrant) if err != nil { return err } if usergrant.HasInvalidRoles(existingRoleKeys) { return zerrors.ThrowPreconditionFailed(err, "COMMAND-mm9F4", "Errors.Project.Role.NotFound") } - return nil + if check != nil { + return check(usergrant.ProjectID, usergrant.ProjectGrantID)(usergrant.ResourceOwner, "") + } + return checkExplicitProjectPermission(ctx, usergrant.ProjectGrantID, usergrant.ProjectID) } // this code needs to be rewritten anyways as soon as we improved the fields handling // //nolint:gocognit -func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGrant *domain.UserGrant, resourceOwner string) (existingRoleKeys []string, err error) { +func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGrant *domain.UserGrant) (existingRoleKeys []string, err error) { criteria := []map[eventstore.FieldType]any{ // project state query { @@ -327,7 +358,7 @@ func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGra // granted org query { eventstore.FieldTypeAggregateType: org.AggregateType, - eventstore.FieldTypeAggregateID: resourceOwner, + eventstore.FieldTypeAggregateID: userGrant.ResourceOwner, eventstore.FieldTypeFieldName: org.OrgStateSearchField, eventstore.FieldTypeObjectType: org.OrgSearchType, }, @@ -386,7 +417,7 @@ func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGra case project.ProjectGrantGrantedOrgIDSearchField: var orgID string err := result.Value.Unmarshal(&orgID) - if err != nil || orgID != resourceOwner { + if err != nil || orgID != userGrant.ResourceOwner { return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-3m9gg", "Errors.Org.NotFound") } case project.ProjectGrantStateSearchField: @@ -425,26 +456,63 @@ func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGra return existingRoleKeys, nil } -func (c *Commands) checkUserGrantPreConditionOld(ctx context.Context, usergrant *domain.UserGrant, resourceOwner string) (err error) { +func (c *Commands) checkUserGrantPreConditionOld(ctx context.Context, usergrant *domain.UserGrant, check UserGrantPermissionCheck) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - preConditions := NewUserGrantPreConditionReadModel(usergrant.UserID, usergrant.ProjectID, usergrant.ProjectGrantID, resourceOwner) + preConditions := NewUserGrantPreConditionReadModel(usergrant.UserID, usergrant.ProjectID, usergrant.ProjectGrantID, usergrant.ResourceOwner) err = c.eventstore.FilterToQueryReducer(ctx, preConditions) if err != nil { return err } + if usergrant.ResourceOwner == "" { + usergrant.ResourceOwner = preConditions.ProjectResourceOwner + } + if usergrant.ProjectGrantID == "" { + usergrant.ProjectGrantID = preConditions.ProjectGrantID + } if !preConditions.UserExists { return zerrors.ThrowPreconditionFailed(err, "COMMAND-4f8sg", "Errors.User.NotFound") } - if usergrant.ProjectGrantID == "" && !preConditions.ProjectExists { + projectIsOwned := usergrant.ResourceOwner == "" || usergrant.ResourceOwner == preConditions.ProjectResourceOwner + if projectIsOwned && !preConditions.ProjectExists { return zerrors.ThrowPreconditionFailed(err, "COMMAND-3n77S", "Errors.Project.NotFound") } - if usergrant.ProjectGrantID != "" && !preConditions.ProjectGrantExists { + if !projectIsOwned && !preConditions.ProjectGrantExists { return zerrors.ThrowPreconditionFailed(err, "COMMAND-4m9ff", "Errors.Project.Grant.NotFound") } if usergrant.HasInvalidRoles(preConditions.ExistingRoleKeys) { return zerrors.ThrowPreconditionFailed(err, "COMMAND-mm9F4", "Errors.Project.Role.NotFound") } - return nil + if check != nil { + return check(usergrant.ProjectID, usergrant.ProjectGrantID)(usergrant.ResourceOwner, "") + } + return checkExplicitProjectPermission(ctx, usergrant.ProjectGrantID, usergrant.ProjectID) +} + +func (c *Commands) searchProjectOwnerAndGrantID(ctx context.Context, projectID string, grantedOrgID string) (projectOwner string, grantID string, err error) { + grantIDQuery := map[eventstore.FieldType]any{ + eventstore.FieldTypeAggregateType: project.AggregateType, + eventstore.FieldTypeAggregateID: projectID, + eventstore.FieldTypeFieldName: project.ProjectGrantGrantedOrgIDSearchField, + } + if grantedOrgID != "" { + grantIDQuery[eventstore.FieldTypeValue] = grantedOrgID + grantIDQuery[eventstore.FieldTypeObjectType] = project.ProjectGrantSearchType + + } + results, err := c.eventstore.Search(ctx, grantIDQuery) + if err != nil { + return "", "", err + } + for _, result := range results { + projectOwner = result.Aggregate.ResourceOwner + if grantedOrgID != "" && grantedOrgID == projectOwner { + return projectOwner, "", nil + } + if result.Object.Type == project.ProjectGrantSearchType { + return projectOwner, result.Object.ID, nil + } + } + return projectOwner, grantID, err } diff --git a/internal/command/user_grant_model.go b/internal/command/user_grant_model.go index b2490177d9..b35c96a2d5 100644 --- a/internal/command/user_grant_model.go +++ b/internal/command/user_grant_model.go @@ -36,6 +36,7 @@ func (wm *UserGrantWriteModel) Reduce() error { wm.ProjectGrantID = e.ProjectGrantID wm.RoleKeys = e.RoleKeys wm.State = domain.UserGrantStateActive + wm.ResourceOwner = e.Aggregate().ResourceOwner case *usergrant.UserGrantChangedEvent: wm.RoleKeys = e.RoleKeys case *usergrant.UserGrantCascadeChangedEvent: @@ -86,17 +87,18 @@ func UserGrantAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Agg type UserGrantPreConditionReadModel struct { eventstore.WriteModel - UserID string - ProjectID string - ProjectGrantID string - ResourceOwner string - UserExists bool - ProjectExists bool - ProjectGrantExists bool - ExistingRoleKeys []string + UserID string + ProjectID string + ProjectResourceOwner string + ProjectGrantID string + ResourceOwner string + UserExists bool + ProjectExists bool + ProjectGrantExists bool + ExistingRoleKeys []string } -func NewUserGrantPreConditionReadModel(userID, projectID, projectGrantID, resourceOwner string) *UserGrantPreConditionReadModel { +func NewUserGrantPreConditionReadModel(userID, projectID, projectGrantID string, resourceOwner string) *UserGrantPreConditionReadModel { return &UserGrantPreConditionReadModel{ UserID: userID, ProjectID: projectID, @@ -117,15 +119,19 @@ func (wm *UserGrantPreConditionReadModel) Reduce() error { case *user.UserRemovedEvent: wm.UserExists = false case *project.ProjectAddedEvent: - if wm.ProjectGrantID == "" && wm.ResourceOwner == e.Aggregate().ResourceOwner { + if wm.ResourceOwner == "" || wm.ResourceOwner == e.Aggregate().ResourceOwner { wm.ProjectExists = true } + wm.ProjectResourceOwner = e.Aggregate().ResourceOwner case *project.ProjectRemovedEvent: wm.ProjectExists = false case *project.GrantAddedEvent: - if wm.ProjectGrantID == e.GrantID && wm.ResourceOwner == e.GrantedOrgID { + if (wm.ProjectGrantID == e.GrantID || wm.ProjectGrantID == "") && wm.ResourceOwner != "" && wm.ResourceOwner == e.GrantedOrgID { wm.ProjectGrantExists = true wm.ExistingRoleKeys = e.RoleKeys + if wm.ProjectGrantID == "" { + wm.ProjectGrantID = e.GrantID + } } case *project.GrantChangedEvent: if wm.ProjectGrantID == e.GrantID { diff --git a/internal/command/user_grant_test.go b/internal/command/user_grant_test.go index dec5903fe8..b12e190d82 100644 --- a/internal/command/user_grant_test.go +++ b/internal/command/user_grant_test.go @@ -2,6 +2,7 @@ package command import ( "context" + "errors" "testing" "github.com/stretchr/testify/assert" @@ -19,15 +20,27 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +var ( + errMockedPermissionCheck = errors.New("mocked permission check error") + isMockedPermissionCheckErr = func(err error) bool { + return errors.Is(err, errMockedPermissionCheck) + } + succeedingUserGrantPermissionCheck = func(_, _ string) PermissionCheck { + return func(_, _ string) error { return nil } + } + failingUserGrantPermissionCheck = func(_, _ string) PermissionCheck { + return func(_, _ string) error { return errMockedPermissionCheck } + } +) + func TestCommandSide_AddUserGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator func(t *testing.T) id.Generator } type args struct { - ctx context.Context - userGrant *domain.UserGrant - resourceOwner string + ctx context.Context + userGrant *domain.UserGrant } type res struct { want *domain.UserGrant @@ -42,16 +55,16 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "invalid usergrant, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ UserID: "user1", + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -60,8 +73,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "user removed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -94,8 +106,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { userGrant: &domain.UserGrant{ UserID: "user1", ProjectID: "project1", + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -104,8 +118,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project removed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -143,8 +156,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { userGrant: &domain.UserGrant{ UserID: "user1", ProjectID: "project1", + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -153,8 +168,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project on other org, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -185,8 +199,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { userGrant: &domain.UserGrant{ UserID: "user1", ProjectID: "project1", + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org2", + }, }, - resourceOwner: "org2", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -195,8 +211,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project roles not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -228,8 +243,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { UserID: "user1", ProjectID: "project1", RoleKeys: []string{"roleKey"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -238,8 +255,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project grant not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -272,8 +288,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"roleKey"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -282,8 +300,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project grant roles not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -332,8 +349,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"roleKey"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -342,8 +361,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project grant on other org, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -392,8 +410,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"rolekey1"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org2", + }, }, - resourceOwner: "org2", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -402,8 +422,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "usergrant for project, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -445,7 +464,82 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "usergrant1"), + idGenerator: func(t *testing.T) id.Generator { + return id_mock.NewIDGeneratorExpectIDs(t, "usergrant1") + }, + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrant: &domain.UserGrant{ + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + }, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1"}, + State: domain.UserGrantStateActive, + }, + }, + }, + { + name: "usergrant without resource owner on project, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + ), + expectPush( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", + []string{"rolekey1"}, + ), + ), + ), + idGenerator: func(t *testing.T) id.Generator { + return id_mock.NewIDGeneratorExpectIDs(t, "usergrant1") + }, }, args: args{ ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), @@ -454,7 +548,6 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ProjectID: "project1", RoleKeys: []string{"rolekey1"}, }, - resourceOwner: "org1", }, res: res{ want: &domain.UserGrant{ @@ -472,8 +565,90 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "usergrant for projectgrant, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org2").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org2").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org2").Aggregate, + "projectgrant1", + "org1", + []string{"rolekey1"}, + ), + ), + ), + expectPush( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "projectgrant1", + []string{"rolekey1"}, + ), + ), + ), + idGenerator: func(t *testing.T) id.Generator { + return id_mock.NewIDGeneratorExpectIDs(t, "usergrant1") + }, + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrant: &domain.UserGrant{ + UserID: "user1", + ProjectID: "project1", + ProjectGrantID: "projectgrant1", + RoleKeys: []string{"rolekey1"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + }, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + ProjectGrantID: "projectgrant1", + RoleKeys: []string{"rolekey1"}, + State: domain.UserGrantStateActive, + }, + }, + }, + { + name: "usergrant for granted resource owner, ok", + fields: fields{ + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -523,17 +698,20 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "usergrant1"), + idGenerator: func(t *testing.T) id.Generator { + return id_mock.NewIDGeneratorExpectIDs(t, "usergrant1") + }, }, args: args{ ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ - UserID: "user1", - ProjectID: "project1", - ProjectGrantID: "projectgrant1", - RoleKeys: []string{"rolekey1"}, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ want: &domain.UserGrant{ @@ -550,34 +728,111 @@ func TestCommandSide_AddUserGrant(t *testing.T) { }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Commands{ - eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, - } - got, err := r.AddUserGrant(tt.args.ctx, tt.args.userGrant, tt.args.resourceOwner) - if tt.res.err == nil { - assert.NoError(t, err) - } - if tt.res.err != nil && !tt.res.err(err) { - t.Errorf("got wrong err: %v ", err) - } - if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) - } - }) - } + t.Run("without permission check", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + if tt.fields.idGenerator != nil { + r.idGenerator = tt.fields.idGenerator(t) + } + got, err := r.AddUserGrant(tt.args.ctx, tt.args.userGrant, nil) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } + }) + t.Run("with succeeding permission check", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + if tt.fields.idGenerator != nil { + r.idGenerator = tt.fields.idGenerator(t) + } + // we use an empty context and only rely on the permission check implementation + got, err := r.AddUserGrant(context.Background(), tt.args.userGrant, succeedingUserGrantPermissionCheck) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } + }) + t.Run("with failing permission check", func(t *testing.T) { + r := &Commands{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + ), + ), + } + // we use an empty context and only rely on the permission check implementation + _, err := r.AddUserGrant(context.Background(), &domain.UserGrant{ + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + }, failingUserGrantPermissionCheck) + assert.ErrorIs(t, err, errMockedPermissionCheck) + }) } func TestCommandSide_ChangeUserGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { - ctx context.Context - userGrant *domain.UserGrant - resourceOwner string + ctx context.Context + userGrant *domain.UserGrant + permissionCheck UserGrantPermissionCheck + cascade bool + ignoreUnchanged bool } type res struct { want *domain.UserGrant @@ -592,16 +847,16 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "invalid usergrant, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ UserID: "user1", + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -610,28 +865,66 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "invalid permissions, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), - &usergrant.NewAggregate("usergrant1", "org").Aggregate, + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, "user1", "project1", "", []string{"rolekey1"}), ), ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey2", + "rolekey 2", + "", + ), + ), + ), ), }, args: args{ ctx: context.Background(), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPermissionDenied, @@ -640,8 +933,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "usergrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -649,13 +941,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", RoleKeys: []string{"rolekey1"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsNotFound, @@ -664,8 +956,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "usergrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -673,13 +964,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", RoleKeys: []string{"rolekey1"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsNotFound, @@ -688,8 +979,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "usergrant roles not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -705,13 +995,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", RoleKeys: []string{"rolekey1"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -720,8 +1010,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "user removed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -762,12 +1051,12 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -776,8 +1065,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "project removed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -823,12 +1111,12 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -837,8 +1125,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "project roles not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -877,13 +1164,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", RoleKeys: []string{"roleKey"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -892,8 +1179,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "project grant not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -932,14 +1218,14 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"roleKey"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -948,8 +1234,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "project grant roles not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1004,14 +1289,14 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"roleKey"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -1020,8 +1305,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "usergrant for project, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1083,13 +1367,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", RoleKeys: []string{"rolekey1", "rolekey2"}, }, - resourceOwner: "org1", }, res: res{ want: &domain.UserGrant{ @@ -1104,11 +1388,94 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { }, }, }, + { + name: "usergrant for project cascade, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey2", + "rolekey 2", + "", + ), + ), + ), + expectPush( + usergrant.NewUserGrantCascadeChangedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + []string{"rolekey1", "rolekey2"}, + ), + ), + ), + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrant: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + cascade: true, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + State: domain.UserGrantStateActive, + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + }, + }, { name: "usergrant for projectgrant, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1178,14 +1545,14 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"rolekey1", "rolekey2"}, }, - resourceOwner: "org1", }, res: res{ want: &domain.UserGrant{ @@ -1201,13 +1568,307 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { }, }, }, + { + name: "usergrant for project without resource owner, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey2", + "rolekey 2", + "", + ), + ), + ), + expectPush( + usergrant.NewUserGrantChangedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + []string{"rolekey1", "rolekey2"}, + ), + ), + ), + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrant: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + State: domain.UserGrantStateActive, + }, + }, + }, + { + name: "usergrant for project with passed succeeding permission check, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey2", + "rolekey 2", + "", + ), + ), + ), + expectPush( + usergrant.NewUserGrantChangedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + []string{"rolekey1", "rolekey2"}, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrant: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + permissionCheck: succeedingUserGrantPermissionCheck, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + State: domain.UserGrantStateActive, + }, + }, + }, + { + name: "usergrant for project with passed failing permission check, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey2", + "rolekey 2", + "", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrant: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + permissionCheck: failingUserGrantPermissionCheck, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + State: domain.UserGrantStateActive, + }, + err: isMockedPermissionCheckErr, + }, + }, + { + name: "usergrant roles not changed, ignore unchanged, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1", "rolekey2"}), + ), + ), + ), + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrant: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + ignoreUnchanged: true, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + State: domain.UserGrantStateActive, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.ChangeUserGrant(tt.args.ctx, tt.args.userGrant, tt.args.resourceOwner) + got, err := r.ChangeUserGrant(tt.args.ctx, tt.args.userGrant, tt.args.cascade, tt.args.ignoreUnchanged, tt.args.permissionCheck) if tt.res.err == nil { assert.NoError(t, err) } @@ -1223,12 +1884,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { func TestCommandSide_DeactivateUserGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context userGrantID string resourceOwner string + check UserGrantPermissionCheck } type res struct { want *domain.ObjectDetails @@ -1243,12 +1905,10 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { { name: "invalid usergrantID, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), resourceOwner: "org1", }, res: res{ @@ -1256,25 +1916,39 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { }, }, { - name: "invalid resourceOwner, error", + name: "not provided resourceOwner, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectPush( + usergrant.NewUserGrantDeactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate, + ), + ), ), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrantID: "usergrant1", }, res: res{ - err: zerrors.IsErrorInvalidArgument, + want: &domain.ObjectDetails{ + ResourceOwner: "org", + }, }, }, { name: "usergrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1290,8 +1964,7 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { { name: "usergrant removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1320,14 +1993,13 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { }, }, { - name: "no permissions, permisison denied error", + name: "no permissions, permission denied error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), - &usergrant.NewAggregate("usergrant1", "org").Aggregate, + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, "user1", "project1", "", []string{"rolekey1"}), @@ -1345,10 +2017,9 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { }, }, { - name: "already deactivated, precondition error", + name: "already deactivated, ignore, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1370,14 +2041,15 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org", + }, }, }, { name: "deactivated, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1405,13 +2077,70 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { }, }, }, + { + name: "with passed succeeding permission check, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectPush( + usergrant.NewUserGrantDeactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: succeedingUserGrantPermissionCheck, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with passed failing permission check, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: failingUserGrantPermissionCheck, + }, + res: res{ + err: isMockedPermissionCheckErr, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.DeactivateUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner) + got, err := r.DeactivateUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner, tt.args.check) if tt.res.err == nil { assert.NoError(t, err) } @@ -1427,12 +2156,13 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { func TestCommandSide_ReactivateUserGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context userGrantID string resourceOwner string + check UserGrantPermissionCheck } type res struct { want *domain.ObjectDetails @@ -1447,12 +2177,10 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { { name: "invalid usergrantID, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), resourceOwner: "org1", }, res: res{ @@ -1460,25 +2188,43 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { }, }, { - name: "invalid resourceOwner, error", + name: "not provided resourceOwner, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + eventFromEventPusher( + usergrant.NewUserGrantDeactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate), + ), + ), + expectPush( + usergrant.NewUserGrantReactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate, + ), + ), ), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrantID: "usergrant1", }, res: res{ - err: zerrors.IsErrorInvalidArgument, + want: &domain.ObjectDetails{ + ResourceOwner: "org", + }, }, }, { name: "usergrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1494,8 +2240,7 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { { name: "usergrant removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1524,10 +2269,9 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { }, }, { - name: "no permissions, permisison denied error", + name: "no permissions, permission denied error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1553,10 +2297,9 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { }, }, { - name: "already active, precondition error", + name: "already active, ignore, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1569,19 +2312,20 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { ), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrantID: "usergrant1", resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org", + }, }, }, { name: "reactivated, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1613,13 +2357,78 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { }, }, }, + { + name: "with passed succeeding permission check, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + eventFromEventPusher( + usergrant.NewUserGrantDeactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate), + ), + ), + expectPush( + usergrant.NewUserGrantReactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: succeedingUserGrantPermissionCheck, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with passed failing permission check, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + eventFromEventPusher( + usergrant.NewUserGrantDeactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: failingUserGrantPermissionCheck, + }, + res: res{ + err: isMockedPermissionCheckErr, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.ReactivateUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner) + got, err := r.ReactivateUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner, tt.args.check) if tt.res.err == nil { assert.NoError(t, err) } @@ -1635,12 +2444,14 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { func TestCommandSide_RemoveUserGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { - ctx context.Context - userGrantID string - resourceOwner string + ctx context.Context + userGrantID string + resourceOwner string + ignoreNotFound bool + check UserGrantPermissionCheck } type res struct { want *domain.ObjectDetails @@ -1655,12 +2466,10 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { { name: "invalid usergrantID, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), resourceOwner: "org1", }, res: res{ @@ -1670,8 +2479,7 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { { name: "usergrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1684,11 +2492,29 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { err: zerrors.IsNotFound, }, }, + { + name: "usergrant not existing, ignore, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrantID: "usergrant1", + resourceOwner: "org1", + ignoreNotFound: true, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, { name: "usergrant removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1717,10 +2543,9 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { }, }, { - name: "no permissions, permisison denied error", + name: "no permissions, permission denied error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1748,8 +2573,7 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { { name: "remove usergrant project, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1780,11 +2604,43 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { }, }, }, + { + name: "not provided resourceOwner, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectPush( + usergrant.NewUserGrantRemovedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", + ), + ), + ), + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrantID: "usergrant1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, { name: "remove usergrant projectgrant, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1815,13 +2671,73 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { }, }, }, + { + name: "with passed succeeding permission check, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "projectgrant1", []string{"rolekey1"}), + ), + ), + expectPush( + usergrant.NewUserGrantRemovedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "projectgrant1", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: succeedingUserGrantPermissionCheck, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with passed failing permission check, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "projectgrant1", []string{"rolekey1"}), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: failingUserGrantPermissionCheck, + }, + res: res{ + err: isMockedPermissionCheckErr, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.RemoveUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner) + got, err := r.RemoveUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner, tt.args.ignoreNotFound, tt.args.check) if tt.res.err == nil { assert.NoError(t, err) } @@ -1857,9 +2773,7 @@ func TestCommandSide_BulkRemoveUserGrant(t *testing.T) { { name: "empty usergrantid list, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: eventstoreExpect(t), }, args: args{ ctx: context.Background(), diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go index 97596aabd8..fca762cbab 100644 --- a/internal/command/user_human_otp.go +++ b/internal/command/user_human_otp.go @@ -26,7 +26,7 @@ func (c *Commands) ImportHumanTOTP(ctx context.Context, userID, userAgentID, res if err != nil { return err } - if err = c.checkUserExists(ctx, userID, resourceOwner); err != nil { + if _, err = c.checkUserExists(ctx, userID, resourceOwner); err != nil { return err } diff --git a/internal/command/user_idp_link.go b/internal/command/user_idp_link.go index 432d2e0b90..e3484c9753 100644 --- a/internal/command/user_idp_link.go +++ b/internal/command/user_idp_link.go @@ -56,7 +56,7 @@ func (c *Commands) BulkAddedUserIDPLinks(ctx context.Context, userID, resourceOw return zerrors.ThrowInvalidArgument(nil, "COMMAND-Ek9s", "Errors.User.ExternalIDP.MinimumExternalIDPNeeded") } - if err := c.checkUserExists(ctx, userID, resourceOwner); err != nil { + if _, err := c.checkUserExists(ctx, userID, resourceOwner); err != nil { return err } diff --git a/internal/command/user_metadata.go b/internal/command/user_metadata.go index d47c5b61d0..294866e23b 100644 --- a/internal/command/user_metadata.go +++ b/internal/command/user_metadata.go @@ -1,6 +1,7 @@ package command import ( + "bytes" "context" "github.com/zitadel/zitadel/internal/domain" @@ -14,12 +15,25 @@ func (c *Commands) SetUserMetadata(ctx context.Context, metadata *domain.Metadat ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - err = c.checkUserExists(ctx, userID, resourceOwner) + userResourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + + if err := c.checkPermissionUpdateUser(ctx, userResourceOwner, userID); err != nil { + return nil, err + } + + setMetadata, err := c.getUserMetadataModelByID(ctx, userID, userResourceOwner, metadata.Key) if err != nil { return nil, err } - setMetadata := NewUserMetadataWriteModel(userID, resourceOwner, metadata.Key) userAgg := UserAggregateFromWriteModel(&setMetadata.WriteModel) + // return if no change in the metadata + if bytes.Equal(setMetadata.Value, metadata.Value) { + return writeModelToUserMetadata(setMetadata), nil + } + event, err := c.setUserMetadata(ctx, userAgg, metadata) if err != nil { return nil, err @@ -40,20 +54,35 @@ func (c *Commands) BulkSetUserMetadata(ctx context.Context, userID, resourceOwne if len(metadatas) == 0 { return nil, zerrors.ThrowPreconditionFailed(nil, "META-9mm2d", "Errors.Metadata.NoData") } - err = c.checkUserExists(ctx, userID, resourceOwner) + userResourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner) if err != nil { return nil, err } - events := make([]eventstore.Command, len(metadatas)) - setMetadata := NewUserMetadataListWriteModel(userID, resourceOwner) + if err := c.checkPermissionUpdateUser(ctx, userResourceOwner, userID); err != nil { + return nil, err + } + + events := make([]eventstore.Command, 0) + setMetadata, err := c.getUserMetadataListModelByID(ctx, userID, userResourceOwner) + if err != nil { + return nil, err + } userAgg := UserAggregateFromWriteModel(&setMetadata.WriteModel) - for i, data := range metadatas { + for _, data := range metadatas { + // if no change to metadata no event has to be pushed + if existingValue, ok := setMetadata.metadataList[data.Key]; ok && bytes.Equal(existingValue, data.Value) { + continue + } event, err := c.setUserMetadata(ctx, userAgg, data) if err != nil { return nil, err } - events[i] = event + events = append(events, event) + } + // no changes for the metadata + if len(events) == 0 { + return writeModelToObjectDetails(&setMetadata.WriteModel), nil } pushedEvents, err := c.eventstore.Push(ctx, events...) @@ -84,11 +113,16 @@ func (c *Commands) RemoveUserMetadata(ctx context.Context, metadataKey, userID, if metadataKey == "" { return nil, zerrors.ThrowInvalidArgument(nil, "META-2n0fs", "Errors.Metadata.Invalid") } - err = c.checkUserExists(ctx, userID, resourceOwner) + userResourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner) if err != nil { return nil, err } - removeMetadata, err := c.getUserMetadataModelByID(ctx, userID, resourceOwner, metadataKey) + + if err := c.checkPermissionUpdateUser(ctx, userResourceOwner, userID); err != nil { + return nil, err + } + + removeMetadata, err := c.getUserMetadataModelByID(ctx, userID, userResourceOwner, metadataKey) if err != nil { return nil, err } @@ -116,13 +150,17 @@ func (c *Commands) BulkRemoveUserMetadata(ctx context.Context, userID, resourceO if len(metadataKeys) == 0 { return nil, zerrors.ThrowPreconditionFailed(nil, "META-9mm2d", "Errors.Metadata.NoData") } - err = c.checkUserExists(ctx, userID, resourceOwner) + userResourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner) if err != nil { return nil, err } + if err := c.checkPermissionUpdateUser(ctx, userResourceOwner, userID); err != nil { + return nil, err + } + events := make([]eventstore.Command, len(metadataKeys)) - removeMetadata, err := c.getUserMetadataListModelByID(ctx, userID, resourceOwner) + removeMetadata, err := c.getUserMetadataListModelByID(ctx, userID, userResourceOwner) if err != nil { return nil, err } @@ -153,24 +191,6 @@ func (c *Commands) BulkRemoveUserMetadata(ctx context.Context, userID, resourceO return writeModelToObjectDetails(&removeMetadata.WriteModel), nil } -func (c *Commands) removeUserMetadataFromOrg(ctx context.Context, resourceOwner string) ([]eventstore.Command, error) { - existingUserMetadata, err := c.getUserMetadataByOrgListModelByID(ctx, resourceOwner) - if err != nil { - return nil, err - } - if len(existingUserMetadata.UserMetadata) == 0 { - return nil, nil - } - events := make([]eventstore.Command, 0) - for key, value := range existingUserMetadata.UserMetadata { - if len(value) == 0 { - continue - } - events = append(events, user.NewMetadataRemovedAllEvent(ctx, &user.NewAggregate(key, resourceOwner).Aggregate)) - } - return events, nil -} - func (c *Commands) removeUserMetadata(ctx context.Context, userAgg *eventstore.Aggregate, metadataKey string) (command eventstore.Command, err error) { command = user.NewMetadataRemovedEvent( ctx, @@ -197,12 +217,3 @@ func (c *Commands) getUserMetadataListModelByID(ctx context.Context, userID, res } return userMetadataWriteModel, nil } - -func (c *Commands) getUserMetadataByOrgListModelByID(ctx context.Context, resourceOwner string) (*UserMetadataByOrgListWriteModel, error) { - userMetadataWriteModel := NewUserMetadataByOrgListWriteModel(resourceOwner) - err := c.eventstore.FilterToQueryReducer(ctx, userMetadataWriteModel) - if err != nil { - return nil, err - } - return userMetadataWriteModel, nil -} diff --git a/internal/command/user_metadata_test.go b/internal/command/user_metadata_test.go index b3ffa7b823..e8fe25acd9 100644 --- a/internal/command/user_metadata_test.go +++ b/internal/command/user_metadata_test.go @@ -16,7 +16,8 @@ import ( func TestCommandSide_SetUserMetadata(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type ( args struct { @@ -39,10 +40,10 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { { name: "user not existing, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -60,8 +61,7 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { { name: "invalid metadata, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -78,7 +78,17 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key", + []byte("value"), + ), + ), + ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -93,10 +103,9 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { }, }, { - name: "add metadata, ok", + name: "add metadata, no permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -113,6 +122,43 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { ), ), ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadata: &domain.Metadata{ + Key: "key", + Value: []byte("value"), + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "add metadata, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter(), expectPush( user.NewMetadataSetEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -121,6 +167,7 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -143,11 +190,116 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { }, }, }, + { + name: "add metadata, reset, invalid", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key", + []byte("value"), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadata: &domain.Metadata{ + Key: "key", + }, + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "add metadata, reset, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key", + []byte("value"), + ), + ), + ), + expectPush( + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key", + []byte("value2"), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadata: &domain.Metadata{ + Key: "key", + Value: []byte("value2"), + }, + }, + res: res{ + want: &domain.Metadata{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + Key: "key", + Value: []byte("value2"), + State: domain.MetadataStateActive, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.SetUserMetadata(tt.args.ctx, tt.args.metadata, tt.args.userID, tt.args.orgID) if tt.res.err == nil { @@ -165,7 +317,8 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { func TestCommandSide_BulkSetUserMetadata(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type ( args struct { @@ -188,9 +341,7 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { { name: "empty meta data list, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -204,8 +355,7 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { { name: "user not existing, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -225,8 +375,7 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { { name: "invalid metadata, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -243,7 +392,9 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { ), ), ), + expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -259,10 +410,9 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { }, }, { - name: "add metadata, ok", + name: "add metadata, no permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -279,6 +429,43 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { ), ), ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadataList: []*domain.Metadata{ + {Key: "key", Value: []byte("value")}, + {Key: "key1", Value: []byte("value1")}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "add metadata, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter(), expectPush( user.NewMetadataSetEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -292,6 +479,7 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -312,7 +500,8 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.BulkSetUserMetadata(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.metadataList...) if tt.res.err == nil { @@ -330,7 +519,8 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { func TestCommandSide_UserRemoveMetadata(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type ( args struct { @@ -353,8 +543,7 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { { name: "user not existing, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -371,9 +560,7 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { { name: "invalid metadata, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -388,8 +575,7 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { { name: "meta data not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -408,6 +594,7 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -419,11 +606,43 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { err: zerrors.IsNotFound, }, }, + { + name: "remove metadata, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadataKey: "key", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, { name: "remove metadata, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -456,6 +675,7 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -473,7 +693,8 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.RemoveUserMetadata(tt.args.ctx, tt.args.metadataKey, tt.args.userID, tt.args.orgID) if tt.res.err == nil { @@ -491,7 +712,8 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type ( args struct { @@ -514,9 +736,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { { name: "empty meta data list, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -530,8 +750,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { { name: "user not existing, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -548,8 +767,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { { name: "remove metadata keys not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -576,6 +794,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -590,8 +809,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { { name: "invalid metadata, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -625,6 +843,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -636,11 +855,43 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { err: zerrors.IsErrorInvalidArgument, }, }, + { + name: "remove metadata, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadataList: []string{"key", "key1"}, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, { name: "remove metadata, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -684,6 +935,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -701,7 +953,8 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.BulkRemoveUserMetadata(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.metadataList...) if tt.res.err == nil { diff --git a/internal/command/user_v2.go b/internal/command/user_v2.go index be10fd03fe..d6c5e7de53 100644 --- a/internal/command/user_v2.go +++ b/internal/command/user_v2.go @@ -2,7 +2,6 @@ package command import ( "context" - "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/domain" @@ -150,7 +149,7 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin events = append(events, user.NewUserRemovedEvent(ctx, &existingUser.Aggregate().Aggregate, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain)) for _, grantID := range cascadingGrantIDs { - removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true) + removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true, true, nil) if err != nil { logging.WithFields("usergrantid", grantID).WithError(err).Warn("could not cascade remove role on user grant") continue diff --git a/internal/crypto/rsa.go b/internal/crypto/rsa.go index 198610d8aa..3fd9a77569 100644 --- a/internal/crypto/rsa.go +++ b/internal/crypto/rsa.go @@ -21,14 +21,6 @@ func GenerateKeyPair(bits int) (*rsa.PrivateKey, *rsa.PublicKey, error) { return privkey, &privkey.PublicKey, nil } -func GenerateEncryptedKeyPair(bits int, alg EncryptionAlgorithm) (*CryptoValue, *CryptoValue, error) { - privateKey, publicKey, err := GenerateKeyPair(bits) - if err != nil { - return nil, nil, err - } - return EncryptKeys(privateKey, publicKey, alg) -} - type CertificateInformations struct { SerialNumber *big.Int Organisation []string diff --git a/internal/domain/application_oidc.go b/internal/domain/application_oidc.go index 5d466c689d..10a70a1776 100644 --- a/internal/domain/application_oidc.go +++ b/internal/domain/application_oidc.go @@ -1,6 +1,7 @@ package domain import ( + "slices" "strings" "time" @@ -32,22 +33,22 @@ type OIDCApp struct { RedirectUris []string ResponseTypes []OIDCResponseType GrantTypes []OIDCGrantType - ApplicationType OIDCApplicationType - AuthMethodType OIDCAuthMethodType + ApplicationType *OIDCApplicationType + AuthMethodType *OIDCAuthMethodType PostLogoutRedirectUris []string - OIDCVersion OIDCVersion + OIDCVersion *OIDCVersion Compliance *Compliance - DevMode bool - AccessTokenType OIDCTokenType - AccessTokenRoleAssertion bool - IDTokenRoleAssertion bool - IDTokenUserinfoAssertion bool - ClockSkew time.Duration + DevMode *bool + AccessTokenType *OIDCTokenType + AccessTokenRoleAssertion *bool + IDTokenRoleAssertion *bool + IDTokenUserinfoAssertion *bool + ClockSkew *time.Duration AdditionalOrigins []string - SkipNativeAppSuccessPage bool - BackChannelLogoutURI string - LoginVersion LoginVersion - LoginBaseURI string + SkipNativeAppSuccessPage *bool + BackChannelLogoutURI *string + LoginVersion *LoginVersion + LoginBaseURI *string State AppState } @@ -69,7 +70,7 @@ func (a *OIDCApp) setClientSecret(encodedHash string) { } func (a *OIDCApp) requiresClientSecret() bool { - return a.AuthMethodType == OIDCAuthMethodTypeBasic || a.AuthMethodType == OIDCAuthMethodTypePost + return a.AuthMethodType != nil && (*a.AuthMethodType == OIDCAuthMethodTypeBasic || *a.AuthMethodType == OIDCAuthMethodTypePost) } type OIDCVersion int32 @@ -137,7 +138,7 @@ const ( ) func (a *OIDCApp) IsValid() bool { - if a.ClockSkew > time.Second*5 || a.ClockSkew < time.Second*0 || !a.OriginsValid() { + if (a.ClockSkew != nil && (*a.ClockSkew > time.Second*5 || *a.ClockSkew < time.Second*0)) || !a.OriginsValid() { return false } grantTypes := a.getRequiredGrantTypes() @@ -204,30 +205,25 @@ func ContainsOIDCGrantTypes(shouldContain, list []OIDCGrantType) bool { } func containsOIDCGrantType(grantTypes []OIDCGrantType, grantType OIDCGrantType) bool { - for _, gt := range grantTypes { - if gt == grantType { - return true - } - } - return false + return slices.Contains(grantTypes, grantType) } func (a *OIDCApp) FillCompliance() { a.Compliance = GetOIDCCompliance(a.OIDCVersion, a.ApplicationType, a.GrantTypes, a.ResponseTypes, a.AuthMethodType, a.RedirectUris) } -func GetOIDCCompliance(version OIDCVersion, appType OIDCApplicationType, grantTypes []OIDCGrantType, responseTypes []OIDCResponseType, authMethod OIDCAuthMethodType, redirectUris []string) *Compliance { - switch version { - case OIDCVersionV1: +func GetOIDCCompliance(version *OIDCVersion, appType *OIDCApplicationType, grantTypes []OIDCGrantType, responseTypes []OIDCResponseType, authMethod *OIDCAuthMethodType, redirectUris []string) *Compliance { + if version != nil && *version == OIDCVersionV1 { return GetOIDCV1Compliance(appType, grantTypes, authMethod, redirectUris) } + return &Compliance{ NoneCompliant: true, Problems: []string{"Application.OIDC.UnsupportedVersion"}, } } -func GetOIDCV1Compliance(appType OIDCApplicationType, grantTypes []OIDCGrantType, authMethod OIDCAuthMethodType, redirectUris []string) *Compliance { +func GetOIDCV1Compliance(appType *OIDCApplicationType, grantTypes []OIDCGrantType, authMethod *OIDCAuthMethodType, redirectUris []string) *Compliance { compliance := &Compliance{NoneCompliant: false} checkGrantTypesCombination(compliance, grantTypes) @@ -247,7 +243,7 @@ func checkGrantTypesCombination(compliance *Compliance, grantTypes []OIDCGrantTy } } -func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appType OIDCApplicationType, redirectUris []string) { +func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appType *OIDCApplicationType, redirectUris []string) { // See #5684 for OIDCGrantTypeDeviceCode and redirectUris further explanation if len(redirectUris) == 0 && (!containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) || (containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) && containsOIDCGrantType(grantTypes, OIDCGrantTypeAuthorizationCode))) { compliance.NoneCompliant = true @@ -266,53 +262,58 @@ func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appTy } } -func checkApplicationType(compliance *Compliance, appType OIDCApplicationType, authMethod OIDCAuthMethodType) { - switch appType { - case OIDCApplicationTypeNative: - GetOIDCV1NativeApplicationCompliance(compliance, authMethod) - case OIDCApplicationTypeUserAgent: - GetOIDCV1UserAgentApplicationCompliance(compliance, authMethod) +func checkApplicationType(compliance *Compliance, appType *OIDCApplicationType, authMethod *OIDCAuthMethodType) { + if appType != nil { + switch *appType { + case OIDCApplicationTypeNative: + GetOIDCV1NativeApplicationCompliance(compliance, authMethod) + case OIDCApplicationTypeUserAgent: + GetOIDCV1UserAgentApplicationCompliance(compliance, authMethod) + case OIDCApplicationTypeWeb: + return + } } + if compliance.NoneCompliant { compliance.Problems = append([]string{"Application.OIDC.V1.NotCompliant"}, compliance.Problems...) } } -func GetOIDCV1NativeApplicationCompliance(compliance *Compliance, authMethod OIDCAuthMethodType) { - if authMethod != OIDCAuthMethodTypeNone { +func GetOIDCV1NativeApplicationCompliance(compliance *Compliance, authMethod *OIDCAuthMethodType) { + if authMethod != nil && *authMethod != OIDCAuthMethodTypeNone { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.AuthMethodType.NotNone") } } -func GetOIDCV1UserAgentApplicationCompliance(compliance *Compliance, authMethod OIDCAuthMethodType) { - if authMethod != OIDCAuthMethodTypeNone { +func GetOIDCV1UserAgentApplicationCompliance(compliance *Compliance, authMethod *OIDCAuthMethodType) { + if authMethod != nil && *authMethod != OIDCAuthMethodTypeNone { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.UserAgent.AuthMethodType.NotNone") } } -func CheckRedirectUrisCode(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisCode(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeUserAgent { + if appType != nil && *appType == OIDCApplicationTypeUserAgent { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb") } - if appType == OIDCApplicationTypeNative && !onlyLocalhostIsHttp(redirectUris) { + if appType != nil && *appType == OIDCApplicationTypeNative && !onlyLocalhostIsHttp(redirectUris) { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") } } - if containsCustom(redirectUris) && appType != OIDCApplicationTypeNative { + if containsCustom(redirectUris) && appType != nil && *appType != OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.CustomOnlyForNative") } } -func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisImplicit(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } @@ -321,7 +322,7 @@ func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationTy compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed") } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeNative { + if appType != nil && *appType == OIDCApplicationTypeNative { if !onlyLocalhostIsHttp(redirectUris) { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") @@ -333,20 +334,20 @@ func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationTy } } -func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } - if containsCustom(redirectUris) && appType != OIDCApplicationTypeNative { + if containsCustom(redirectUris) && appType != nil && *appType != OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed") } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeUserAgent { + if appType != nil && *appType == OIDCApplicationTypeUserAgent { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb") } - if !onlyLocalhostIsHttp(redirectUris) && appType == OIDCApplicationTypeNative { + if !onlyLocalhostIsHttp(redirectUris) && appType != nil && *appType == OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") } diff --git a/internal/domain/application_oidc_test.go b/internal/domain/application_oidc_test.go index b3d9488827..4208917cdd 100644 --- a/internal/domain/application_oidc_test.go +++ b/internal/domain/application_oidc_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" ) @@ -25,7 +27,7 @@ func TestApplicationValid(t *testing.T) { ObjectRoot: models.ObjectRoot{AggregateID: "AggregateID"}, AppID: "AppID", AppName: "AppName", - ClockSkew: time.Minute * 1, + ClockSkew: gu.Ptr(time.Minute * 1), ResponseTypes: []OIDCResponseType{OIDCResponseTypeCode}, GrantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, }, @@ -39,7 +41,7 @@ func TestApplicationValid(t *testing.T) { ObjectRoot: models.ObjectRoot{AggregateID: "AggregateID"}, AppID: "AppID", AppName: "AppName", - ClockSkew: time.Minute * -1, + ClockSkew: gu.Ptr(time.Minute * -1), ResponseTypes: []OIDCResponseType{OIDCResponseTypeCode}, GrantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, }, @@ -190,9 +192,9 @@ func TestApplicationValid(t *testing.T) { func TestGetOIDCV1Compliance(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType grantTypes []OIDCGrantType - authMethod OIDCAuthMethodType + authMethod *OIDCAuthMethodType redirectUris []string } tests := []struct { @@ -266,7 +268,7 @@ func Test_checkGrantTypesCombination(t *testing.T) { func Test_checkRedirectURIs(t *testing.T) { type args struct { grantTypes []OIDCGrantType - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -304,7 +306,7 @@ func Test_checkRedirectURIs(t *testing.T) { args: args{ redirectUris: []string{"http://redirect.to/me"}, grantTypes: []OIDCGrantType{OIDCGrantTypeImplicit}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, }, { @@ -316,7 +318,7 @@ func Test_checkRedirectURIs(t *testing.T) { args: args{ redirectUris: []string{"http://redirect.to/me"}, grantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, }, } @@ -338,7 +340,7 @@ func Test_checkRedirectURIs(t *testing.T) { func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -356,17 +358,6 @@ func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { redirectUris: []string{"https://redirect.to/me"}, }, }, - // { - // name: "custom protocol, not native", - // want: &Compliance{ - // NoneCompliant: true, - // Problems: []string{"Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed"}, - // }, - // args: args{ - // redirectUris: []string{"protocol://redirect.to/me"}, - // appType: OIDCApplicationTypeWeb, - // }, - // }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -386,7 +377,7 @@ func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -402,7 +393,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "custom protocol not native app", args: args{ - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), redirectUris: []string{"custom://nirvana.com"}, }, want: &Compliance{ @@ -413,7 +404,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "http localhost user agent app", args: args{ - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), redirectUris: []string{"http://localhost:9009"}, }, want: &Compliance{ @@ -424,7 +415,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "http, not only localhost native app", args: args{ - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), redirectUris: []string{"http://nirvana.com", "http://localhost:9009"}, }, want: &Compliance{ @@ -435,7 +426,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "not allowed combination", args: args{ - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), redirectUris: []string{"https://nirvana.com", "cutom://nirvana.com"}, }, want: &Compliance{ @@ -461,7 +452,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { func TestCheckRedirectUrisImplicit(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -488,7 +479,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type native, not only localhost", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: true, @@ -499,7 +490,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type native, only localhost", args: args{ redirectUris: []string{"http://localhost:8080"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: false, @@ -510,7 +501,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type web", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, @@ -535,7 +526,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { func TestCheckRedirectUrisCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -552,7 +543,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "custom prefix, app type web", args: args{ redirectUris: []string{"custom://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, @@ -563,7 +554,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "only http protocol, app type user agent", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, want: &Compliance{ NoneCompliant: true, @@ -574,7 +565,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "only http protocol, app type native, only localhost", args: args{ redirectUris: []string{"http://localhost:8080", "http://nirvana.com:8080"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: true, @@ -585,7 +576,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "custom protocol, not native", args: args{ redirectUris: []string{"custom://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, diff --git a/internal/domain/application_saml.go b/internal/domain/application_saml.go index de7ef789ee..aff1875c7e 100644 --- a/internal/domain/application_saml.go +++ b/internal/domain/application_saml.go @@ -11,9 +11,9 @@ type SAMLApp struct { AppName string EntityID string Metadata []byte - MetadataURL string - LoginVersion LoginVersion - LoginBaseURI string + MetadataURL *string + LoginVersion *LoginVersion + LoginBaseURI *string State AppState } @@ -31,11 +31,14 @@ func (a *SAMLApp) GetMetadata() []byte { } func (a *SAMLApp) GetMetadataURL() string { - return a.MetadataURL + if a.MetadataURL != nil { + return *a.MetadataURL + } + return "" } func (a *SAMLApp) IsValid() bool { - if a.MetadataURL == "" && a.Metadata == nil { + if (a.MetadataURL == nil || *a.MetadataURL == "") && a.Metadata == nil { return false } return true diff --git a/internal/domain/member.go b/internal/domain/member.go index 821e65dfe3..76854a6568 100644 --- a/internal/domain/member.go +++ b/internal/domain/member.go @@ -25,10 +25,6 @@ func (i *Member) IsValid() bool { return i.AggregateID != "" && i.UserID != "" && len(i.Roles) != 0 } -func (i *Member) IsIAMValid() bool { - return i.UserID != "" && len(i.Roles) != 0 -} - type MemberState int32 const ( @@ -42,3 +38,7 @@ const ( func (f MemberState) Valid() bool { return f >= 0 && f < memberStateCount } + +func (f MemberState) Exists() bool { + return f != MemberStateRemoved && f != MemberStateUnspecified +} diff --git a/internal/domain/mock/permission.go b/internal/domain/mock/permission.go new file mode 100644 index 0000000000..9a3c6478c9 --- /dev/null +++ b/internal/domain/mock/permission.go @@ -0,0 +1,22 @@ +package permissionmock + +import ( + "golang.org/x/net/context" + + "github.com/zitadel/zitadel/internal/domain" +) + +// MockPermissionCheckErr returns a permission check function that will fail +// and return the input error +func MockPermissionCheckErr(err error) domain.PermissionCheck { + return func(_ context.Context, _, _, _ string) error { + return err + } +} + +// MockPermissionCheckOK returns a permission check function that will succeed +func MockPermissionCheckOK() domain.PermissionCheck { + return func(_ context.Context, _, _, _ string) (err error) { + return nil + } +} diff --git a/internal/domain/permission.go b/internal/domain/permission.go index bb569955f5..1405991dae 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -27,26 +27,44 @@ func (p *Permissions) appendPermission(ctxID, permission string) { type PermissionCheck func(ctx context.Context, permission, resourceOwnerID, aggregateID string) (err error) const ( - PermissionUserWrite = "user.write" - PermissionUserRead = "user.read" - PermissionUserDelete = "user.delete" - PermissionUserCredentialWrite = "user.credential.write" - PermissionSessionWrite = "session.write" - PermissionSessionRead = "session.read" - PermissionSessionLink = "session.link" - PermissionSessionDelete = "session.delete" - PermissionOrgRead = "org.read" - PermissionIDPRead = "iam.idp.read" - PermissionOrgIDPRead = "org.idp.read" - PermissionProjectWrite = "project.write" - PermissionProjectRead = "project.read" - PermissionProjectDelete = "project.delete" - PermissionProjectGrantWrite = "project.grant.write" - PermissionProjectGrantRead = "project.grant.read" - PermissionProjectGrantDelete = "project.grant.delete" - PermissionProjectRoleWrite = "project.role.write" - PermissionProjectRoleRead = "project.role.read" - PermissionProjectRoleDelete = "project.role.delete" + PermissionUserWrite = "user.write" + PermissionUserRead = "user.read" + PermissionUserDelete = "user.delete" + PermissionUserCredentialWrite = "user.credential.write" + PermissionSessionWrite = "session.write" + PermissionSessionRead = "session.read" + PermissionSessionLink = "session.link" + PermissionSessionDelete = "session.delete" + PermissionOrgRead = "org.read" + PermissionIDPRead = "iam.idp.read" + PermissionOrgIDPRead = "org.idp.read" + PermissionProjectWrite = "project.write" + PermissionProjectRead = "project.read" + PermissionProjectDelete = "project.delete" + PermissionProjectGrantWrite = "project.grant.write" + PermissionProjectGrantRead = "project.grant.read" + PermissionProjectGrantDelete = "project.grant.delete" + PermissionProjectRoleWrite = "project.role.write" + PermissionProjectRoleRead = "project.role.read" + PermissionProjectRoleDelete = "project.role.delete" + PermissionProjectAppWrite = "project.app.write" + PermissionProjectAppDelete = "project.app.delete" + PermissionProjectAppRead = "project.app.read" + PermissionInstanceMemberWrite = "iam.member.write" + PermissionInstanceMemberDelete = "iam.member.delete" + PermissionInstanceMemberRead = "iam.member.read" + PermissionOrgMemberWrite = "org.member.write" + PermissionOrgMemberDelete = "org.member.delete" + PermissionOrgMemberRead = "org.member.read" + PermissionProjectMemberWrite = "project.member.write" + PermissionProjectMemberDelete = "project.member.delete" + PermissionProjectMemberRead = "project.member.read" + PermissionProjectGrantMemberWrite = "project.grant.member.write" + PermissionProjectGrantMemberDelete = "project.grant.member.delete" + PermissionProjectGrantMemberRead = "project.grant.member.read" + PermissionUserGrantWrite = "user.grant.write" + PermissionUserGrantRead = "user.grant.read" + PermissionUserGrantDelete = "user.grant.delete" ) // ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants. diff --git a/internal/domain/roles.go b/internal/domain/roles.go index c40eef6120..2cebd26d30 100644 --- a/internal/domain/roles.go +++ b/internal/domain/roles.go @@ -14,6 +14,7 @@ const ( RoleOrgOwner = "ORG_OWNER" RoleOrgProjectCreator = "ORG_PROJECT_CREATOR" RoleIAMOwner = "IAM_OWNER" + RoleIAMLoginClient = "IAM_LOGIN_CLIENT" RoleProjectOwner = "PROJECT_OWNER" RoleProjectOwnerGlobal = "PROJECT_OWNER_GLOBAL" RoleProjectGrantOwner = "PROJECT_GRANT_OWNER" diff --git a/internal/feature/feature.go b/internal/feature/feature.go index b5f5a901d4..5e28338904 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -9,22 +9,20 @@ import ( type Key int const ( - KeyUnspecified Key = iota - KeyLoginDefaultOrg - KeyTriggerIntrospectionProjections - KeyLegacyIntrospection - KeyUserSchema - KeyTokenExchange - KeyActionsDeprecated - KeyImprovedPerformance - KeyWebKey - KeyDebugOIDCParentError - KeyOIDCSingleV1SessionTermination - KeyDisableUserTokenEvent - KeyEnableBackChannelLogout - KeyLoginV2 - KeyPermissionCheckV2 - KeyConsoleUseV2UserApi + // Reserved: 2, 3, 6, 8 + + KeyUnspecified Key = 0 + KeyLoginDefaultOrg Key = 1 + KeyUserSchema Key = 4 + KeyTokenExchange Key = 5 + KeyImprovedPerformance Key = 7 + KeyDebugOIDCParentError Key = 9 + KeyOIDCSingleV1SessionTermination Key = 10 + KeyDisableUserTokenEvent Key = 11 + KeyEnableBackChannelLogout Key = 12 + KeyLoginV2 Key = 13 + KeyPermissionCheckV2 Key = 14 + KeyConsoleUseV2UserApi Key = 15 ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -41,20 +39,17 @@ const ( ) type Features struct { - LoginDefaultOrg bool `json:"login_default_org,omitempty"` - TriggerIntrospectionProjections bool `json:"trigger_introspection_projections,omitempty"` - LegacyIntrospection bool `json:"legacy_introspection,omitempty"` - UserSchema bool `json:"user_schema,omitempty"` - TokenExchange bool `json:"token_exchange,omitempty"` - ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` - WebKey bool `json:"web_key,omitempty"` - DebugOIDCParentError bool `json:"debug_oidc_parent_error,omitempty"` - OIDCSingleV1SessionTermination bool `json:"oidc_single_v1_session_termination,omitempty"` - DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` - EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"` - LoginV2 LoginV2 `json:"login_v2,omitempty"` - PermissionCheckV2 bool `json:"permission_check_v2,omitempty"` - ConsoleUseV2UserApi bool `json:"console_use_v2_user_api,omitempty"` + LoginDefaultOrg bool `json:"login_default_org,omitempty"` + UserSchema bool `json:"user_schema,omitempty"` + TokenExchange bool `json:"token_exchange,omitempty"` + ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` + DebugOIDCParentError bool `json:"debug_oidc_parent_error,omitempty"` + OIDCSingleV1SessionTermination bool `json:"oidc_single_v1_session_termination,omitempty"` + DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` + EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"` + LoginV2 LoginV2 `json:"login_v2,omitempty"` + PermissionCheckV2 bool `json:"permission_check_v2,omitempty"` + ConsoleUseV2UserApi bool `json:"console_use_v2_user_api,omitempty"` } /* Note: do not generate the stringer or enumer for this type, is it breaks existing events */ diff --git a/internal/feature/feature_test.go b/internal/feature/feature_test.go index 70e3ec9ffb..d9a459e3db 100644 --- a/internal/feature/feature_test.go +++ b/internal/feature/feature_test.go @@ -11,8 +11,6 @@ func TestKey(t *testing.T) { tests := []string{ "unspecified", "login_default_org", - "trigger_introspection_projections", - "legacy_introspection", } for _, want := range tests { t.Run(want, func(t *testing.T) { diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index a47b3eb4d9..9d6f5877e0 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -7,17 +7,39 @@ import ( "strings" ) -const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactions_deprecatedimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +const ( + _KeyName_0 = "unspecifiedlogin_default_org" + _KeyLowerName_0 = "unspecifiedlogin_default_org" + _KeyName_1 = "user_schematoken_exchange" + _KeyLowerName_1 = "user_schematoken_exchange" + _KeyName_2 = "improved_performance" + _KeyLowerName_2 = "improved_performance" + _KeyName_3 = "debug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" + _KeyLowerName_3 = "debug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +) -var _KeyIndex = [...]uint16{0, 11, 28, 61, 81, 92, 106, 124, 144, 151, 174, 208, 232, 258, 266, 285, 308} - -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactions_deprecatedimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +var ( + _KeyIndex_0 = [...]uint8{0, 11, 28} + _KeyIndex_1 = [...]uint8{0, 11, 25} + _KeyIndex_2 = [...]uint8{0, 20} + _KeyIndex_3 = [...]uint8{0, 23, 57, 81, 107, 115, 134, 157} +) func (i Key) String() string { - if i < 0 || i >= Key(len(_KeyIndex)-1) { + switch { + case 0 <= i && i <= 1: + return _KeyName_0[_KeyIndex_0[i]:_KeyIndex_0[i+1]] + case 4 <= i && i <= 5: + i -= 4 + return _KeyName_1[_KeyIndex_1[i]:_KeyIndex_1[i+1]] + case i == 7: + return _KeyName_2 + case 9 <= i && i <= 15: + i -= 9 + return _KeyName_3[_KeyIndex_3[i]:_KeyIndex_3[i+1]] + default: return fmt.Sprintf("Key(%d)", i) } - return _KeyName[_KeyIndex[i]:_KeyIndex[i+1]] } // An "invalid array index" compiler error signifies that the constant values have changed. @@ -26,13 +48,9 @@ func _KeyNoOp() { var x [1]struct{} _ = x[KeyUnspecified-(0)] _ = x[KeyLoginDefaultOrg-(1)] - _ = x[KeyTriggerIntrospectionProjections-(2)] - _ = x[KeyLegacyIntrospection-(3)] _ = x[KeyUserSchema-(4)] _ = x[KeyTokenExchange-(5)] - _ = x[KeyActionsDeprecated-(6)] _ = x[KeyImprovedPerformance-(7)] - _ = x[KeyWebKey-(8)] _ = x[KeyDebugOIDCParentError-(9)] _ = x[KeyOIDCSingleV1SessionTermination-(10)] _ = x[KeyDisableUserTokenEvent-(11)] @@ -42,60 +60,48 @@ func _KeyNoOp() { _ = x[KeyConsoleUseV2UserApi-(15)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActionsDeprecated, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} var _KeyNameToValueMap = map[string]Key{ - _KeyName[0:11]: KeyUnspecified, - _KeyLowerName[0:11]: KeyUnspecified, - _KeyName[11:28]: KeyLoginDefaultOrg, - _KeyLowerName[11:28]: KeyLoginDefaultOrg, - _KeyName[28:61]: KeyTriggerIntrospectionProjections, - _KeyLowerName[28:61]: KeyTriggerIntrospectionProjections, - _KeyName[61:81]: KeyLegacyIntrospection, - _KeyLowerName[61:81]: KeyLegacyIntrospection, - _KeyName[81:92]: KeyUserSchema, - _KeyLowerName[81:92]: KeyUserSchema, - _KeyName[92:106]: KeyTokenExchange, - _KeyLowerName[92:106]: KeyTokenExchange, - _KeyName[106:124]: KeyActionsDeprecated, - _KeyLowerName[106:124]: KeyActionsDeprecated, - _KeyName[124:144]: KeyImprovedPerformance, - _KeyLowerName[124:144]: KeyImprovedPerformance, - _KeyName[144:151]: KeyWebKey, - _KeyLowerName[144:151]: KeyWebKey, - _KeyName[151:174]: KeyDebugOIDCParentError, - _KeyLowerName[151:174]: KeyDebugOIDCParentError, - _KeyName[174:208]: KeyOIDCSingleV1SessionTermination, - _KeyLowerName[174:208]: KeyOIDCSingleV1SessionTermination, - _KeyName[208:232]: KeyDisableUserTokenEvent, - _KeyLowerName[208:232]: KeyDisableUserTokenEvent, - _KeyName[232:258]: KeyEnableBackChannelLogout, - _KeyLowerName[232:258]: KeyEnableBackChannelLogout, - _KeyName[258:266]: KeyLoginV2, - _KeyLowerName[258:266]: KeyLoginV2, - _KeyName[266:285]: KeyPermissionCheckV2, - _KeyLowerName[266:285]: KeyPermissionCheckV2, - _KeyName[285:308]: KeyConsoleUseV2UserApi, - _KeyLowerName[285:308]: KeyConsoleUseV2UserApi, + _KeyName_0[0:11]: KeyUnspecified, + _KeyLowerName_0[0:11]: KeyUnspecified, + _KeyName_0[11:28]: KeyLoginDefaultOrg, + _KeyLowerName_0[11:28]: KeyLoginDefaultOrg, + _KeyName_1[0:11]: KeyUserSchema, + _KeyLowerName_1[0:11]: KeyUserSchema, + _KeyName_1[11:25]: KeyTokenExchange, + _KeyLowerName_1[11:25]: KeyTokenExchange, + _KeyName_2[0:20]: KeyImprovedPerformance, + _KeyLowerName_2[0:20]: KeyImprovedPerformance, + _KeyName_3[0:23]: KeyDebugOIDCParentError, + _KeyLowerName_3[0:23]: KeyDebugOIDCParentError, + _KeyName_3[23:57]: KeyOIDCSingleV1SessionTermination, + _KeyLowerName_3[23:57]: KeyOIDCSingleV1SessionTermination, + _KeyName_3[57:81]: KeyDisableUserTokenEvent, + _KeyLowerName_3[57:81]: KeyDisableUserTokenEvent, + _KeyName_3[81:107]: KeyEnableBackChannelLogout, + _KeyLowerName_3[81:107]: KeyEnableBackChannelLogout, + _KeyName_3[107:115]: KeyLoginV2, + _KeyLowerName_3[107:115]: KeyLoginV2, + _KeyName_3[115:134]: KeyPermissionCheckV2, + _KeyLowerName_3[115:134]: KeyPermissionCheckV2, + _KeyName_3[134:157]: KeyConsoleUseV2UserApi, + _KeyLowerName_3[134:157]: KeyConsoleUseV2UserApi, } var _KeyNames = []string{ - _KeyName[0:11], - _KeyName[11:28], - _KeyName[28:61], - _KeyName[61:81], - _KeyName[81:92], - _KeyName[92:106], - _KeyName[106:124], - _KeyName[124:144], - _KeyName[144:151], - _KeyName[151:174], - _KeyName[174:208], - _KeyName[208:232], - _KeyName[232:258], - _KeyName[258:266], - _KeyName[266:285], - _KeyName[285:308], + _KeyName_0[0:11], + _KeyName_0[11:28], + _KeyName_1[0:11], + _KeyName_1[11:25], + _KeyName_2[0:20], + _KeyName_3[0:23], + _KeyName_3[23:57], + _KeyName_3[57:81], + _KeyName_3[81:107], + _KeyName_3[107:115], + _KeyName_3[115:134], + _KeyName_3[134:157], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/idp/providers/apple/apple_test.go b/internal/idp/providers/apple/apple_test.go index f3b7e81a1a..7d1f3a8481 100644 --- a/internal/idp/providers/apple/apple_test.go +++ b/internal/idp/providers/apple/apple_test.go @@ -62,10 +62,10 @@ func TestProvider_BeginAuth(t *testing.T) { ctx := context.Background() session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - content, redirect := session.GetAuth(ctx) - contentExpected, redirectExpected := tt.want.GetAuth(ctx) - a.Equal(redirectExpected, redirect) - a.Equal(contentExpected, content) + auth, err := session.GetAuth(ctx) + authExpected, errExpected := tt.want.GetAuth(ctx) + a.ErrorIs(err, errExpected) + a.Equal(authExpected, auth) }) } } diff --git a/internal/idp/providers/azuread/azuread_test.go b/internal/idp/providers/azuread/azuread_test.go index 122a70bb07..e46815cc8e 100644 --- a/internal/idp/providers/azuread/azuread_test.go +++ b/internal/idp/providers/azuread/azuread_test.go @@ -81,10 +81,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/azuread/session.go b/internal/idp/providers/azuread/session.go index 169784fb58..f417897893 100644 --- a/internal/idp/providers/azuread/session.go +++ b/internal/idp/providers/azuread/session.go @@ -28,7 +28,7 @@ func NewSession(provider *Provider, code string) *Session { } // GetAuth implements the [idp.Provider] interface by calling the wrapped [oauth.Session]. -func (s *Session) GetAuth(ctx context.Context) (content string, redirect bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return s.oauth().GetAuth(ctx) } diff --git a/internal/idp/providers/github/github_test.go b/internal/idp/providers/github/github_test.go index 6274b51841..42f03c050d 100644 --- a/internal/idp/providers/github/github_test.go +++ b/internal/idp/providers/github/github_test.go @@ -48,10 +48,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/gitlab/gitlab_test.go b/internal/idp/providers/gitlab/gitlab_test.go index 24b813bc81..99b28c5003 100644 --- a/internal/idp/providers/gitlab/gitlab_test.go +++ b/internal/idp/providers/gitlab/gitlab_test.go @@ -59,10 +59,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/google/google_test.go b/internal/idp/providers/google/google_test.go index b95f8eaf9f..b8f31b86e3 100644 --- a/internal/idp/providers/google/google_test.go +++ b/internal/idp/providers/google/google_test.go @@ -48,10 +48,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/jwt/jwt_test.go b/internal/idp/providers/jwt/jwt_test.go index 5756c58e07..aba337d2ee 100644 --- a/internal/idp/providers/jwt/jwt_test.go +++ b/internal/idp/providers/jwt/jwt_test.go @@ -119,10 +119,10 @@ func TestProvider_BeginAuth(t *testing.T) { } if tt.want.err == nil { a.NoError(err) - wantHeaders, wantContent := tt.want.session.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.session.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) } }) } diff --git a/internal/idp/providers/jwt/session.go b/internal/idp/providers/jwt/session.go index 85b164a9c5..0d91986fc9 100644 --- a/internal/idp/providers/jwt/session.go +++ b/internal/idp/providers/jwt/session.go @@ -42,7 +42,7 @@ func NewSessionFromRequest(provider *Provider, r *http.Request) *Session { } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.AuthURL) } diff --git a/internal/idp/providers/ldap/session.go b/internal/idp/providers/ldap/session.go index a78dd02d73..6a56cd6132 100644 --- a/internal/idp/providers/ldap/session.go +++ b/internal/idp/providers/ldap/session.go @@ -39,7 +39,7 @@ func NewSession(provider *Provider, username, password string) *Session { } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.loginUrl) } diff --git a/internal/idp/providers/oauth/oauth2_test.go b/internal/idp/providers/oauth/oauth2_test.go index 984315ac1f..93a0dd404f 100644 --- a/internal/idp/providers/oauth/oauth2_test.go +++ b/internal/idp/providers/oauth/oauth2_test.go @@ -80,10 +80,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/oauth/session.go b/internal/idp/providers/oauth/session.go index c9e175d1cf..27d38b1740 100644 --- a/internal/idp/providers/oauth/session.go +++ b/internal/idp/providers/oauth/session.go @@ -37,7 +37,7 @@ func NewSession(provider *Provider, code string, idpArguments map[string]any) *S } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.AuthURL) } diff --git a/internal/idp/providers/oidc/oidc_test.go b/internal/idp/providers/oidc/oidc_test.go index a46f09f13f..86e23f95d2 100644 --- a/internal/idp/providers/oidc/oidc_test.go +++ b/internal/idp/providers/oidc/oidc_test.go @@ -98,10 +98,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/oidc/session.go b/internal/idp/providers/oidc/session.go index 9e1e55baf5..08e277a9cc 100644 --- a/internal/idp/providers/oidc/session.go +++ b/internal/idp/providers/oidc/session.go @@ -33,7 +33,7 @@ func NewSession(provider *Provider, code string, idpArguments map[string]any) *S } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.AuthURL) } diff --git a/internal/idp/providers/saml/saml_test.go b/internal/idp/providers/saml/saml_test.go index 69ff231ccc..5e76e6dcaa 100644 --- a/internal/idp/providers/saml/saml_test.go +++ b/internal/idp/providers/saml/saml_test.go @@ -1,7 +1,9 @@ package saml import ( + "context" "encoding/xml" + "net/url" "testing" "time" @@ -11,10 +13,138 @@ import ( "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/idp" "github.com/zitadel/zitadel/internal/idp/providers/saml/requesttracker" "github.com/zitadel/zitadel/internal/zerrors" ) +func TestProvider_BeginAuth(t *testing.T) { + requestTracker := requesttracker.New( + func(ctx context.Context, authRequestID, samlRequestID string) error { + assert.Equal(t, "state", authRequestID) + return nil + }, + func(ctx context.Context, authRequestID string) (*samlsp.TrackedRequest, error) { + return &samlsp.TrackedRequest{ + SAMLRequestID: "state", + Index: authRequestID, + }, nil + }, + ) + type fields struct { + name string + rootURL string + metadata []byte + certificate []byte + key []byte + options []ProviderOpts + } + type args struct { + state string + } + type want struct { + err func(error) bool + authType idp.Auth + ssoURL string + relayState string + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "redirect binding, success", + fields: fields{ + name: "saml", + rootURL: "https://localhost:8080", + metadata: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + certificate: []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + key: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + options: []ProviderOpts{ + WithCustomRequestTracker(requestTracker), + }, + }, + args: args{ + state: "state", + }, + want: want{ + authType: &idp.RedirectAuth{}, + ssoURL: "http://localhost:8000/sso", + relayState: "state", + }, + }, + { + name: "post binding, success", + fields: fields{ + name: "saml", + rootURL: "https://localhost:8080", + metadata: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + certificate: []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + key: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + options: []ProviderOpts{ + WithCustomRequestTracker(requestTracker), + }, + }, + args: args{ + state: "state", + }, + want: want{ + authType: &idp.FormAuth{}, + ssoURL: "http://localhost:8000/sso", + relayState: "state", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + + provider, err := New( + tt.fields.name, + tt.fields.rootURL, + tt.fields.metadata, + tt.fields.certificate, + tt.fields.key, + tt.fields.options..., + ) + require.NoError(t, err) + + ctx := context.Background() + session, err := provider.BeginAuth(ctx, tt.args.state, nil) + if tt.want.err != nil && !tt.want.err(err) { + a.Fail("invalid error", err) + } + if tt.want.err == nil { + a.NoError(err) + gotAuth, gotErr := session.GetAuth(ctx) + a.NoError(gotErr) + a.IsType(tt.want.authType, gotAuth) + + var ssoURL, relayState, samlRequest string + switch auth := gotAuth.(type) { + case *idp.RedirectAuth: + gotRedirect, err := url.Parse(auth.RedirectURL) + a.NoError(err) + gotQuery := gotRedirect.Query() + + ssoURL = gotRedirect.Scheme + "://" + gotRedirect.Host + gotRedirect.Path + relayState = gotQuery.Get("RelayState") + samlRequest = gotQuery.Get("SAMLRequest") + case *idp.FormAuth: + ssoURL = auth.URL + relayState = auth.Fields["RelayState"] + samlRequest = auth.Fields["SAMLRequest"] + } + a.Equal(tt.want.ssoURL, ssoURL) + a.Equal(tt.want.relayState, relayState) + a.NotEmpty(samlRequest) + } + }) + } +} + func TestProvider_Options(t *testing.T) { type fields struct { name string diff --git a/internal/idp/providers/saml/session.go b/internal/idp/providers/saml/session.go index e2a1655a26..e1f32209b0 100644 --- a/internal/idp/providers/saml/session.go +++ b/internal/idp/providers/saml/session.go @@ -1,13 +1,14 @@ package saml import ( - "bytes" "context" + "encoding/base64" "errors" "net/http" "net/url" "time" + "github.com/beevik/etree" "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" @@ -43,22 +44,15 @@ func NewSession(provider *Provider, requestID string, request *http.Request) (*S } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { - url, _ := url.Parse(s.state) - resp := NewTempResponseWriter() - +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { + url, err := url.Parse(s.state) + if err != nil { + return nil, err + } request := &http.Request{ URL: url, } - s.ServiceProvider.HandleStartAuthFlow( - resp, - request.WithContext(ctx), - ) - - if location := resp.Header().Get("Location"); location != "" { - return idp.Redirect(location) - } - return idp.Form(resp.content.String()) + return s.auth(request.WithContext(ctx)) } // PersistentParameters implements the [idp.Session] interface. @@ -130,24 +124,57 @@ func (s *Session) transientMappingID() (string, error) { return "", zerrors.ThrowInvalidArgument(nil, "SAML-swwg2", "Errors.Intent.MissingSingleMappingAttribute") } -type TempResponseWriter struct { - header http.Header - content *bytes.Buffer -} - -func (w *TempResponseWriter) Header() http.Header { - return w.header -} - -func (w *TempResponseWriter) Write(content []byte) (int, error) { - return w.content.Write(content) -} - -func (w *TempResponseWriter) WriteHeader(statusCode int) {} - -func NewTempResponseWriter() *TempResponseWriter { - return &TempResponseWriter{ - header: map[string][]string{}, - content: bytes.NewBuffer([]byte{}), +// auth is a modified copy of the [samlsp.Middleware.HandleStartAuthFlow] method. +// Instead of writing the response to the http.ResponseWriter, it returns the auth request as an [idp.Auth]. +// In case of an error, it returns the error directly and does not write to the response. +func (s *Session) auth(r *http.Request) (idp.Auth, error) { + if r.URL.Path == s.ServiceProvider.ServiceProvider.AcsURL.Path { + // should never occur, but was handled in the original method, so we keep it here + return nil, zerrors.ThrowInvalidArgument(nil, "SAML-Eoi24", "don't wrap Middleware with RequireAccount") } + + var binding, bindingLocation string + if s.ServiceProvider.Binding != "" { + binding = s.ServiceProvider.Binding + bindingLocation = s.ServiceProvider.ServiceProvider.GetSSOBindingLocation(binding) + } else { + binding = saml.HTTPRedirectBinding + bindingLocation = s.ServiceProvider.ServiceProvider.GetSSOBindingLocation(binding) + if bindingLocation == "" { + binding = saml.HTTPPostBinding + bindingLocation = s.ServiceProvider.ServiceProvider.GetSSOBindingLocation(binding) + } + } + + authReq, err := s.ServiceProvider.ServiceProvider.MakeAuthenticationRequest(bindingLocation, binding, s.ServiceProvider.ResponseBinding) + if err != nil { + return nil, err + } + relayState, err := s.ServiceProvider.RequestTracker.TrackRequest(nil, r, authReq.ID) + if err != nil { + return nil, err + } + + if binding == saml.HTTPRedirectBinding { + redirectURL, err := authReq.Redirect(relayState, &s.ServiceProvider.ServiceProvider) + if err != nil { + return nil, err + } + return idp.Redirect(redirectURL.String()) + } + if binding == saml.HTTPPostBinding { + doc := etree.NewDocument() + doc.SetRoot(authReq.Element()) + reqBuf, err := doc.WriteToBytes() + if err != nil { + return nil, err + } + encodedReqBuf := base64.StdEncoding.EncodeToString(reqBuf) + return idp.Form(authReq.Destination, + map[string]string{ + "SAMLRequest": encodedReqBuf, + "RelayState": relayState, + }) + } + return nil, zerrors.ThrowInvalidArgument(nil, "SAML-Eoi24", "Errors.Intent.Invalid") } diff --git a/internal/idp/session.go b/internal/idp/session.go index fc593eb820..d0df3415bf 100644 --- a/internal/idp/session.go +++ b/internal/idp/session.go @@ -7,12 +7,29 @@ import ( // Session is the minimal implementation for a session of a 3rd party authentication [Provider] type Session interface { - GetAuth(ctx context.Context) (content string, redirect bool) + GetAuth(ctx context.Context) (Auth, error) PersistentParameters() map[string]any FetchUser(ctx context.Context) (User, error) ExpiresAt() time.Time } +type Auth interface { + auth() +} + +type RedirectAuth struct { + RedirectURL string +} + +func (r *RedirectAuth) auth() {} + +type FormAuth struct { + URL string + Fields map[string]string +} + +func (f *FormAuth) auth() {} + // SessionSupportsMigration is an optional extension to the Session interface. // It can be implemented to support migrating users, were the initial external id has changed because of a migration of the Provider type. // E.g. when a user was linked on a generic OIDC provider and this provider has now been migrated to an AzureAD provider. @@ -22,10 +39,13 @@ type SessionSupportsMigration interface { RetrievePreviousID() (previousID string, err error) } -func Redirect(redirectURL string) (string, bool) { - return redirectURL, true +func Redirect(redirectURL string) (*RedirectAuth, error) { + return &RedirectAuth{RedirectURL: redirectURL}, nil } -func Form(html string) (string, bool) { - return html, false +func Form(url string, fields map[string]string) (*FormAuth, error) { + return &FormAuth{ + URL: url, + Fields: fields, + }, nil } diff --git a/internal/integration/client.go b/internal/integration/client.go index 20c98b5628..a89e4fa621 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -2,6 +2,7 @@ package integration import ( "context" + "encoding/base64" "fmt" "sync" "testing" @@ -22,12 +23,15 @@ import ( "github.com/zitadel/zitadel/internal/integration/scim" action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" "github.com/zitadel/zitadel/pkg/grpc/admin" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" "github.com/zitadel/zitadel/pkg/grpc/auth" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" "github.com/zitadel/zitadel/pkg/grpc/idp" idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + internal_permission_v2beta "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/object/v2" object_v3alpha "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" @@ -46,35 +50,40 @@ import ( user_pb "github.com/zitadel/zitadel/pkg/grpc/user" user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2" user_v2beta "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + webkey_v2 "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" webkey_v2beta "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" ) type Client struct { - CC *grpc.ClientConn - Admin admin.AdminServiceClient - Mgmt mgmt.ManagementServiceClient - Auth auth.AuthServiceClient - UserV2beta user_v2beta.UserServiceClient - UserV2 user_v2.UserServiceClient - SessionV2beta session_v2beta.SessionServiceClient - SessionV2 session.SessionServiceClient - SettingsV2beta settings_v2beta.SettingsServiceClient - SettingsV2 settings.SettingsServiceClient - OIDCv2beta oidc_pb_v2beta.OIDCServiceClient - OIDCv2 oidc_pb.OIDCServiceClient - OrgV2beta org_v2beta.OrganizationServiceClient - OrgV2 org.OrganizationServiceClient - ActionV2beta action.ActionServiceClient - FeatureV2beta feature_v2beta.FeatureServiceClient - FeatureV2 feature.FeatureServiceClient - UserSchemaV3 userschema_v3alpha.ZITADELUserSchemasClient - WebKeyV2Beta webkey_v2beta.WebKeyServiceClient - IDPv2 idp_pb.IdentityProviderServiceClient - UserV3Alpha user_v3alpha.ZITADELUsersClient - SAMLv2 saml_pb.SAMLServiceClient - SCIM *scim.Client - Projectv2Beta project_v2beta.ProjectServiceClient - InstanceV2Beta instance.InstanceServiceClient + CC *grpc.ClientConn + Admin admin.AdminServiceClient + Mgmt mgmt.ManagementServiceClient + Auth auth.AuthServiceClient + UserV2beta user_v2beta.UserServiceClient + UserV2 user_v2.UserServiceClient + SessionV2beta session_v2beta.SessionServiceClient + SessionV2 session.SessionServiceClient + SettingsV2beta settings_v2beta.SettingsServiceClient + SettingsV2 settings.SettingsServiceClient + OIDCv2beta oidc_pb_v2beta.OIDCServiceClient + OIDCv2 oidc_pb.OIDCServiceClient + OrgV2beta org_v2beta.OrganizationServiceClient + OrgV2 org.OrganizationServiceClient + ActionV2beta action.ActionServiceClient + FeatureV2beta feature_v2beta.FeatureServiceClient + FeatureV2 feature.FeatureServiceClient + UserSchemaV3 userschema_v3alpha.ZITADELUserSchemasClient + WebKeyV2 webkey_v2.WebKeyServiceClient + WebKeyV2Beta webkey_v2beta.WebKeyServiceClient + IDPv2 idp_pb.IdentityProviderServiceClient + UserV3Alpha user_v3alpha.ZITADELUsersClient + SAMLv2 saml_pb.SAMLServiceClient + SCIM *scim.Client + Projectv2Beta project_v2beta.ProjectServiceClient + InstanceV2Beta instance.InstanceServiceClient + AppV2Beta app.AppServiceClient + InternalPermissionv2Beta internal_permission_v2beta.InternalPermissionServiceClient + AuthorizationV2Beta authorization.AuthorizationServiceClient } func NewDefaultClient(ctx context.Context) (*Client, error) { @@ -89,31 +98,35 @@ func newClient(ctx context.Context, target string) (*Client, error) { return nil, err } client := &Client{ - CC: cc, - Admin: admin.NewAdminServiceClient(cc), - Mgmt: mgmt.NewManagementServiceClient(cc), - Auth: auth.NewAuthServiceClient(cc), - UserV2beta: user_v2beta.NewUserServiceClient(cc), - UserV2: user_v2.NewUserServiceClient(cc), - SessionV2beta: session_v2beta.NewSessionServiceClient(cc), - SessionV2: session.NewSessionServiceClient(cc), - SettingsV2beta: settings_v2beta.NewSettingsServiceClient(cc), - SettingsV2: settings.NewSettingsServiceClient(cc), - OIDCv2beta: oidc_pb_v2beta.NewOIDCServiceClient(cc), - OIDCv2: oidc_pb.NewOIDCServiceClient(cc), - OrgV2beta: org_v2beta.NewOrganizationServiceClient(cc), - OrgV2: org.NewOrganizationServiceClient(cc), - ActionV2beta: action.NewActionServiceClient(cc), - FeatureV2beta: feature_v2beta.NewFeatureServiceClient(cc), - FeatureV2: feature.NewFeatureServiceClient(cc), - UserSchemaV3: userschema_v3alpha.NewZITADELUserSchemasClient(cc), - WebKeyV2Beta: webkey_v2beta.NewWebKeyServiceClient(cc), - IDPv2: idp_pb.NewIdentityProviderServiceClient(cc), - UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), - SAMLv2: saml_pb.NewSAMLServiceClient(cc), - SCIM: scim.NewScimClient(target), - Projectv2Beta: project_v2beta.NewProjectServiceClient(cc), - InstanceV2Beta: instance.NewInstanceServiceClient(cc), + CC: cc, + Admin: admin.NewAdminServiceClient(cc), + Mgmt: mgmt.NewManagementServiceClient(cc), + Auth: auth.NewAuthServiceClient(cc), + UserV2beta: user_v2beta.NewUserServiceClient(cc), + UserV2: user_v2.NewUserServiceClient(cc), + SessionV2beta: session_v2beta.NewSessionServiceClient(cc), + SessionV2: session.NewSessionServiceClient(cc), + SettingsV2beta: settings_v2beta.NewSettingsServiceClient(cc), + SettingsV2: settings.NewSettingsServiceClient(cc), + OIDCv2beta: oidc_pb_v2beta.NewOIDCServiceClient(cc), + OIDCv2: oidc_pb.NewOIDCServiceClient(cc), + OrgV2beta: org_v2beta.NewOrganizationServiceClient(cc), + OrgV2: org.NewOrganizationServiceClient(cc), + ActionV2beta: action.NewActionServiceClient(cc), + FeatureV2beta: feature_v2beta.NewFeatureServiceClient(cc), + FeatureV2: feature.NewFeatureServiceClient(cc), + UserSchemaV3: userschema_v3alpha.NewZITADELUserSchemasClient(cc), + WebKeyV2: webkey_v2.NewWebKeyServiceClient(cc), + WebKeyV2Beta: webkey_v2beta.NewWebKeyServiceClient(cc), + IDPv2: idp_pb.NewIdentityProviderServiceClient(cc), + UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), + SAMLv2: saml_pb.NewSAMLServiceClient(cc), + SCIM: scim.NewScimClient(target), + Projectv2Beta: project_v2beta.NewProjectServiceClient(cc), + InstanceV2Beta: instance.NewInstanceServiceClient(cc), + AppV2Beta: app.NewAppServiceClient(cc), + InternalPermissionv2Beta: internal_permission_v2beta.NewInternalPermissionServiceClient(cc), + AuthorizationV2Beta: authorization.NewAuthorizationServiceClient(cc), } return client, client.pollHealth(ctx) } @@ -233,7 +246,29 @@ func (i *Instance) CreateHumanUserWithTOTP(ctx context.Context, secret string) * return resp } -func (i *Instance) CreateUserTypeHuman(ctx context.Context) *user_v2.CreateUserResponse { +func (i *Instance) SetUserMetadata(ctx context.Context, id, key, value string) *user_v2.SetUserMetadataResponse { + resp, err := i.Client.UserV2.SetUserMetadata(ctx, &user_v2.SetUserMetadataRequest{ + UserId: id, + Metadata: []*user_v2.Metadata{{ + Key: key, + Value: []byte(base64.StdEncoding.EncodeToString([]byte(value))), + }, + }, + }) + logging.OnError(err).Panic("set user metadata") + return resp +} + +func (i *Instance) DeleteUserMetadata(ctx context.Context, id, key string) *user_v2.DeleteUserMetadataResponse { + resp, err := i.Client.UserV2.DeleteUserMetadata(ctx, &user_v2.DeleteUserMetadataRequest{ + UserId: id, + Keys: []string{key}, + }) + logging.OnError(err).Panic("delete user metadata") + return resp +} + +func (i *Instance) CreateUserTypeHuman(ctx context.Context, email string) *user_v2.CreateUserResponse { resp, err := i.Client.UserV2.CreateUser(ctx, &user_v2.CreateUserRequest{ OrganizationId: i.DefaultOrg.GetId(), UserType: &user_v2.CreateUserRequest_Human_{ @@ -243,7 +278,7 @@ func (i *Instance) CreateUserTypeHuman(ctx context.Context) *user_v2.CreateUserR FamilyName: "Mouse", }, Email: &user_v2.SetHumanEmail{ - Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Email: email, Verification: &user_v2.SetHumanEmail_ReturnCode{ ReturnCode: &user_v2.ReturnEmailVerificationCode{}, }, @@ -256,7 +291,7 @@ func (i *Instance) CreateUserTypeHuman(ctx context.Context) *user_v2.CreateUserR return resp } -func (i *Instance) CreateUserTypeMachine(ctx context.Context) *user_v2.CreateUserResponse { +func (i *Instance) CreateUserTypeMachine(ctx context.Context, orgId string) *user_v2.CreateUserResponse { resp, err := i.Client.UserV2.CreateUser(ctx, &user_v2.CreateUserRequest{ OrganizationId: i.DefaultOrg.GetId(), UserType: &user_v2.CreateUserRequest_Machine_{ @@ -623,14 +658,6 @@ func (i *Instance) AddOrgGenericOAuthProvider(ctx context.Context, name string) }, }) logging.OnError(err).Panic("create generic OAuth idp") - /* - mustAwait(func() error { - _, err := i.Client.Mgmt.GetProviderByID(ctx, &mgmt.GetProviderByIDRequest{ - Id: resp.GetId(), - }) - return err - }) - */ return resp } @@ -877,48 +904,107 @@ func (i *Instance) ActivateProjectGrant(ctx context.Context, t *testing.T, proje return resp } -func (i *Instance) CreateProjectUserGrant(t *testing.T, ctx context.Context, projectID, userID string) string { +func (i *Instance) CreateProjectUserGrant(t *testing.T, ctx context.Context, projectID, userID string) *mgmt.AddUserGrantResponse { resp, err := i.Client.Mgmt.AddUserGrant(ctx, &mgmt.AddUserGrantRequest{ UserId: userID, ProjectId: projectID, }) require.NoError(t, err) - return resp.GetUserGrantId() + return resp } -func (i *Instance) CreateProjectGrantUserGrant(ctx context.Context, orgID, projectID, projectGrantID, userID string) string { +func (i *Instance) CreateProjectGrantUserGrant(ctx context.Context, orgID, projectID, projectGrantID, userID string) *mgmt.AddUserGrantResponse { resp, err := i.Client.Mgmt.AddUserGrant(SetOrgID(ctx, orgID), &mgmt.AddUserGrantRequest{ UserId: userID, ProjectId: projectID, ProjectGrantId: projectGrantID, }) logging.OnError(err).Panic("create project grant user grant") - return resp.GetUserGrantId() + return resp } -func (i *Instance) CreateOrgMembership(t *testing.T, ctx context.Context, userID string) { - _, err := i.Client.Mgmt.AddOrgMember(ctx, &mgmt.AddOrgMemberRequest{ +func (i *Instance) CreateInstanceMembership(t *testing.T, ctx context.Context, userID string) *internal_permission_v2beta.CreateAdministratorResponse { + resp, err := i.Client.InternalPermissionv2Beta.CreateAdministrator(ctx, &internal_permission_v2beta.CreateAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{ + Resource: &internal_permission_v2beta.ResourceType_Instance{Instance: true}, + }, + UserId: userID, + Roles: []string{domain.RoleIAMOwner}, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeleteInstanceMembership(t *testing.T, ctx context.Context, userID string) { + _, err := i.Client.Admin.RemoveIAMMember(ctx, &admin.RemoveIAMMemberRequest{ + UserId: userID, + }) + require.NoError(t, err) +} + +func (i *Instance) CreateOrgMembership(t *testing.T, ctx context.Context, orgID, userID string) *internal_permission_v2beta.CreateAdministratorResponse { + resp, err := i.Client.InternalPermissionv2Beta.CreateAdministrator(ctx, &internal_permission_v2beta.CreateAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{ + Resource: &internal_permission_v2beta.ResourceType_OrganizationId{OrganizationId: orgID}, + }, UserId: userID, Roles: []string{domain.RoleOrgOwner}, }) require.NoError(t, err) + return resp } -func (i *Instance) CreateProjectMembership(t *testing.T, ctx context.Context, projectID, userID string) { - _, err := i.Client.Mgmt.AddProjectMember(ctx, &mgmt.AddProjectMemberRequest{ - ProjectId: projectID, - UserId: userID, - Roles: []string{domain.RoleProjectOwner}, +func (i *Instance) DeleteOrgMembership(t *testing.T, ctx context.Context, userID string) { + _, err := i.Client.Mgmt.RemoveOrgMember(ctx, &mgmt.RemoveOrgMemberRequest{ + UserId: userID, }) require.NoError(t, err) } -func (i *Instance) CreateProjectGrantMembership(t *testing.T, ctx context.Context, projectID, grantID, userID string) { - _, err := i.Client.Mgmt.AddProjectGrantMember(ctx, &mgmt.AddProjectGrantMemberRequest{ - ProjectId: projectID, - GrantId: grantID, - UserId: userID, - Roles: []string{domain.RoleProjectGrantOwner}, +func (i *Instance) CreateProjectMembership(t *testing.T, ctx context.Context, projectID, userID string) *internal_permission_v2beta.CreateAdministratorResponse { + resp, err := i.Client.InternalPermissionv2Beta.CreateAdministrator(ctx, &internal_permission_v2beta.CreateAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{ + Resource: &internal_permission_v2beta.ResourceType_ProjectId{ProjectId: projectID}, + }, + UserId: userID, + Roles: []string{domain.RoleProjectOwner}, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeleteProjectMembership(t *testing.T, ctx context.Context, projectID, userID string) { + _, err := i.Client.InternalPermissionv2Beta.DeleteAdministrator(ctx, &internal_permission_v2beta.DeleteAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{Resource: &internal_permission_v2beta.ResourceType_ProjectId{ProjectId: projectID}}, + UserId: userID, + }) + require.NoError(t, err) +} + +func (i *Instance) CreateProjectGrantMembership(t *testing.T, ctx context.Context, projectID, grantID, userID string) *internal_permission_v2beta.CreateAdministratorResponse { + resp, err := i.Client.InternalPermissionv2Beta.CreateAdministrator(ctx, &internal_permission_v2beta.CreateAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{ + Resource: &internal_permission_v2beta.ResourceType_ProjectGrant_{ProjectGrant: &internal_permission_v2beta.ResourceType_ProjectGrant{ + ProjectId: projectID, + ProjectGrantId: grantID, + }}, + }, + UserId: userID, + Roles: []string{domain.RoleProjectGrantOwner}, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeleteProjectGrantMembership(t *testing.T, ctx context.Context, projectID, grantID, userID string) { + _, err := i.Client.InternalPermissionv2Beta.DeleteAdministrator(ctx, &internal_permission_v2beta.DeleteAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{ + Resource: &internal_permission_v2beta.ResourceType_ProjectGrant_{ProjectGrant: &internal_permission_v2beta.ResourceType_ProjectGrant{ + ProjectId: projectID, + ProjectGrantId: grantID, + }}, + }, + UserId: userID, }) require.NoError(t, err) } diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index bb8d86376d..fed746d823 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -101,3 +101,10 @@ SystemDefaults: KeyConfig: PrivateKeyLifetime: 7200h PublicKeyLifetime: 14400h + +OIDC: + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 + DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 + +SAML: + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 diff --git a/internal/integration/feature.go b/internal/integration/feature.go new file mode 100644 index 0000000000..07942fcdcd --- /dev/null +++ b/internal/integration/feature.go @@ -0,0 +1,30 @@ +package integration + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" +) + +func EnsureInstanceFeature(t *testing.T, ctx context.Context, instance *Instance, features *feature.SetInstanceFeaturesRequest, assertFeatures func(t *assert.CollectT, got *feature.GetInstanceFeaturesResponse)) { + ctx = instance.WithAuthorizationToken(ctx, UserTypeIAMOwner) + _, err := instance.Client.FeatureV2.SetInstanceFeatures(ctx, features) + require.NoError(t, err) + retryDuration, tick := WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, + func(tt *assert.CollectT) { + got, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(tt, err) + assertFeatures(tt, got) + }, + retryDuration, + tick, + "timed out waiting for ensuring instance feature") +} diff --git a/internal/integration/instance.go b/internal/integration/instance.go index 66e2cf18ec..9e262cb3d9 100644 --- a/internal/integration/instance.go +++ b/internal/integration/instance.go @@ -294,11 +294,14 @@ func (i *Instance) createWebAuthNClient() { i.WebAuthN = webauthn.NewClient(i.Config.WebAuthNName, i.Domain, http_util.BuildOrigin(i.Host(), i.Config.Secure)) } +// Deprecated: WithAuthorization is misleading, as we have Zitadel resources called authorization now. +// It is aliased to WithAuthorizationToken, which sets the Authorization header with a Bearer token. +// Use WithAuthorizationToken directly instead. func (i *Instance) WithAuthorization(ctx context.Context, u UserType) context.Context { - return i.WithInstanceAuthorization(ctx, u) + return i.WithAuthorizationToken(ctx, u) } -func (i *Instance) WithInstanceAuthorization(ctx context.Context, u UserType) context.Context { +func (i *Instance) WithAuthorizationToken(ctx context.Context, u UserType) context.Context { return WithAuthorizationToken(ctx, i.Users.Get(u).Token) } diff --git a/internal/notification/handlers/back_channel_logout.go b/internal/notification/handlers/back_channel_logout.go index f1a99146ca..983915ac28 100644 --- a/internal/notification/handlers/back_channel_logout.go +++ b/internal/notification/handlers/back_channel_logout.go @@ -7,10 +7,8 @@ import ( "sync" "time" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/crypto" "github.com/zitadel/oidc/v3/pkg/oidc" - "github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" @@ -149,7 +147,7 @@ func (u *backChannelLogoutNotifier) terminateSession(ctx context.Context, id str return err } - getSigner := zoidc.GetSignerOnce(u.queries.GetActiveSigningWebKey, u.signingKey) + getSigner := zoidc.GetSignerOnce(u.queries.GetActiveSigningWebKey) var wg sync.WaitGroup wg.Add(len(sessions.sessions)) @@ -172,20 +170,6 @@ func (u *backChannelLogoutNotifier) terminateSession(ctx context.Context, id str return errors.Join(errs...) } -func (u *backChannelLogoutNotifier) signingKey(ctx context.Context) (op.SigningKey, error) { - keys, err := u.queries.ActivePrivateSigningKey(ctx, time.Now()) - if err != nil { - return nil, err - } - if len(keys.Keys) == 0 { - logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID()). - Info("There's no active signing key and automatic rotation is not supported for back channel logout." + - "Please enable the webkey management feature on your instance") - return nil, zerrors.ThrowPreconditionFailed(nil, "HANDL-DF3nf", "no active signing key") - } - return zoidc.PrivateKeyToSigningKey(zoidc.SelectSigningKey(keys.Keys), u.keyEncryptionAlg) -} - func (u *backChannelLogoutNotifier) sendLogoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, e eventstore.Event, getSigner zoidc.SignerFunc) error { token, err := u.logoutToken(ctx, oidcSession, getSigner) if err != nil { diff --git a/internal/notification/handlers/mock/commands.mock.go b/internal/notification/handlers/mock/commands.mock.go index 7d41c30f30..ec327de8e8 100644 --- a/internal/notification/handlers/mock/commands.mock.go +++ b/internal/notification/handlers/mock/commands.mock.go @@ -23,6 +23,7 @@ import ( type MockCommands struct { ctrl *gomock.Controller recorder *MockCommandsMockRecorder + isgomock struct{} } // MockCommandsMockRecorder is the mock recorder for MockCommands. @@ -43,197 +44,197 @@ func (m *MockCommands) EXPECT() *MockCommandsMockRecorder { } // HumanEmailVerificationCodeSent mocks base method. -func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), ctx, orgID, userID) } // HumanInitCodeSent mocks base method. -func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) HumanInitCodeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanInitCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanInitCodeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // HumanInitCodeSent indicates an expected call of HumanInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanInitCodeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), ctx, orgID, userID) } // HumanOTPEmailCodeSent mocks base method. -func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", ctx, userID, resourceOwner) ret0, _ := ret[0].(error) return ret0 } // HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(ctx, userID, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), ctx, userID, resourceOwner) } // HumanOTPSMSCodeSent mocks base method. -func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", ctx, userID, resourceOwner, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(ctx, userID, resourceOwner, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), ctx, userID, resourceOwner, generatorInfo) } // HumanPasswordlessInitCodeSent mocks base method. -func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, arg2, arg3 string) error { +func (m *MockCommands) HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", ctx, userID, resourceOwner, codeID) ret0, _ := ret[0].(error) return ret0 } // HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(ctx, userID, resourceOwner, codeID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), ctx, userID, resourceOwner, codeID) } // HumanPhoneVerificationCodeSent mocks base method. -func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", ctx, orgID, userID, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), ctx, orgID, userID, generatorInfo) } // InviteCodeSent mocks base method. -func (m *MockCommands) InviteCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) InviteCodeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InviteCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "InviteCodeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // InviteCodeSent indicates an expected call of InviteCodeSent. -func (mr *MockCommandsMockRecorder) InviteCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) InviteCodeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), ctx, orgID, userID) } // MilestonePushed mocks base method. -func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 string, arg2 milestone.Type, arg3 []string) error { +func (m *MockCommands) MilestonePushed(ctx context.Context, instanceID string, msType milestone.Type, endpoints []string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MilestonePushed", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "MilestonePushed", ctx, instanceID, msType, endpoints) ret0, _ := ret[0].(error) return ret0 } // MilestonePushed indicates an expected call of MilestonePushed. -func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) MilestonePushed(ctx, instanceID, msType, endpoints any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), ctx, instanceID, msType, endpoints) } // OTPEmailSent mocks base method. -func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OTPEmailSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "OTPEmailSent", ctx, sessionID, resourceOwner) ret0, _ := ret[0].(error) return ret0 } // OTPEmailSent indicates an expected call of OTPEmailSent. -func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) OTPEmailSent(ctx, sessionID, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), ctx, sessionID, resourceOwner) } // OTPSMSSent mocks base method. -func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) OTPSMSSent(ctx context.Context, sessionID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OTPSMSSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "OTPSMSSent", ctx, sessionID, resourceOwner, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // OTPSMSSent indicates an expected call of OTPSMSSent. -func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) OTPSMSSent(ctx, sessionID, resourceOwner, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), ctx, sessionID, resourceOwner, generatorInfo) } // PasswordChangeSent mocks base method. -func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) PasswordChangeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PasswordChangeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "PasswordChangeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // PasswordChangeSent indicates an expected call of PasswordChangeSent. -func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) PasswordChangeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), ctx, orgID, userID) } // PasswordCodeSent mocks base method. -func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PasswordCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "PasswordCodeSent", ctx, orgID, userID, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // PasswordCodeSent indicates an expected call of PasswordCodeSent. -func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) PasswordCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), ctx, orgID, userID, generatorInfo) } // UsageNotificationSent mocks base method. -func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.NotificationDueEvent) error { +func (m *MockCommands) UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UsageNotificationSent", arg0, arg1) + ret := m.ctrl.Call(m, "UsageNotificationSent", ctx, dueEvent) ret0, _ := ret[0].(error) return ret0 } // UsageNotificationSent indicates an expected call of UsageNotificationSent. -func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) UsageNotificationSent(ctx, dueEvent any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), ctx, dueEvent) } // UserDomainClaimedSent mocks base method. -func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) UserDomainClaimedSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserDomainClaimedSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "UserDomainClaimedSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent. -func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), ctx, orgID, userID) } diff --git a/internal/notification/handlers/mock/queries.mock.go b/internal/notification/handlers/mock/queries.mock.go index 670d3f3896..2cf53d1b2a 100644 --- a/internal/notification/handlers/mock/queries.mock.go +++ b/internal/notification/handlers/mock/queries.mock.go @@ -12,7 +12,6 @@ package mock import ( context "context" reflect "reflect" - time "time" jose "github.com/go-jose/go-jose/v4" authz "github.com/zitadel/zitadel/internal/api/authz" @@ -26,6 +25,7 @@ import ( type MockQueries struct { ctrl *gomock.Controller recorder *MockQueriesMockRecorder + isgomock struct{} } // MockQueriesMockRecorder is the mock recorder for MockQueries. @@ -60,240 +60,225 @@ func (mr *MockQueriesMockRecorder) ActiveInstances() *gomock.Call { } // ActiveLabelPolicyByOrg mocks base method. -func (m *MockQueries) ActiveLabelPolicyByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.LabelPolicy, error) { +func (m *MockQueries) ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", ctx, orgID, withOwnerRemoved) ret0, _ := ret[0].(*query.LabelPolicy) ret1, _ := ret[1].(error) return ret0, ret1 } // ActiveLabelPolicyByOrg indicates an expected call of ActiveLabelPolicyByOrg. -func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), arg0, arg1, arg2) -} - -// ActivePrivateSigningKey mocks base method. -func (m *MockQueries) ActivePrivateSigningKey(arg0 context.Context, arg1 time.Time) (*query.PrivateKeys, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActivePrivateSigningKey", arg0, arg1) - ret0, _ := ret[0].(*query.PrivateKeys) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ActivePrivateSigningKey indicates an expected call of ActivePrivateSigningKey. -func (mr *MockQueriesMockRecorder) ActivePrivateSigningKey(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActivePrivateSigningKey", reflect.TypeOf((*MockQueries)(nil).ActivePrivateSigningKey), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), ctx, orgID, withOwnerRemoved) } // CustomTextListByTemplate mocks base method. -func (m *MockQueries) CustomTextListByTemplate(arg0 context.Context, arg1, arg2 string, arg3 bool) (*query.CustomTexts, error) { +func (m *MockQueries) CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CustomTextListByTemplate", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "CustomTextListByTemplate", ctx, aggregateID, template, withOwnerRemoved) ret0, _ := ret[0].(*query.CustomTexts) ret1, _ := ret[1].(error) return ret0, ret1 } // CustomTextListByTemplate indicates an expected call of CustomTextListByTemplate. -func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(ctx, aggregateID, template, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), ctx, aggregateID, template, withOwnerRemoved) } // GetActiveSigningWebKey mocks base method. -func (m *MockQueries) GetActiveSigningWebKey(arg0 context.Context) (*jose.JSONWebKey, error) { +func (m *MockQueries) GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetActiveSigningWebKey", arg0) + ret := m.ctrl.Call(m, "GetActiveSigningWebKey", ctx) ret0, _ := ret[0].(*jose.JSONWebKey) ret1, _ := ret[1].(error) return ret0, ret1 } // GetActiveSigningWebKey indicates an expected call of GetActiveSigningWebKey. -func (mr *MockQueriesMockRecorder) GetActiveSigningWebKey(arg0 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetActiveSigningWebKey(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveSigningWebKey", reflect.TypeOf((*MockQueries)(nil).GetActiveSigningWebKey), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveSigningWebKey", reflect.TypeOf((*MockQueries)(nil).GetActiveSigningWebKey), ctx) } // GetDefaultLanguage mocks base method. -func (m *MockQueries) GetDefaultLanguage(arg0 context.Context) language.Tag { +func (m *MockQueries) GetDefaultLanguage(ctx context.Context) language.Tag { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetDefaultLanguage", arg0) + ret := m.ctrl.Call(m, "GetDefaultLanguage", ctx) ret0, _ := ret[0].(language.Tag) return ret0 } // GetDefaultLanguage indicates an expected call of GetDefaultLanguage. -func (mr *MockQueriesMockRecorder) GetDefaultLanguage(arg0 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetDefaultLanguage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), ctx) } // GetInstanceRestrictions mocks base method. -func (m *MockQueries) GetInstanceRestrictions(arg0 context.Context) (query.Restrictions, error) { +func (m *MockQueries) GetInstanceRestrictions(ctx context.Context) (query.Restrictions, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetInstanceRestrictions", arg0) + ret := m.ctrl.Call(m, "GetInstanceRestrictions", ctx) ret0, _ := ret[0].(query.Restrictions) ret1, _ := ret[1].(error) return ret0, ret1 } // GetInstanceRestrictions indicates an expected call of GetInstanceRestrictions. -func (mr *MockQueriesMockRecorder) GetInstanceRestrictions(arg0 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetInstanceRestrictions(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceRestrictions", reflect.TypeOf((*MockQueries)(nil).GetInstanceRestrictions), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceRestrictions", reflect.TypeOf((*MockQueries)(nil).GetInstanceRestrictions), ctx) } // GetNotifyUserByID mocks base method. -func (m *MockQueries) GetNotifyUserByID(arg0 context.Context, arg1 bool, arg2 string) (*query.NotifyUser, error) { +func (m *MockQueries) GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (*query.NotifyUser, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetNotifyUserByID", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetNotifyUserByID", ctx, shouldTriggered, userID) ret0, _ := ret[0].(*query.NotifyUser) ret1, _ := ret[1].(error) return ret0, ret1 } // GetNotifyUserByID indicates an expected call of GetNotifyUserByID. -func (mr *MockQueriesMockRecorder) GetNotifyUserByID(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetNotifyUserByID(ctx, shouldTriggered, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), ctx, shouldTriggered, userID) } // InstanceByID mocks base method. -func (m *MockQueries) InstanceByID(arg0 context.Context, arg1 string) (authz.Instance, error) { +func (m *MockQueries) InstanceByID(ctx context.Context, id string) (authz.Instance, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InstanceByID", arg0, arg1) + ret := m.ctrl.Call(m, "InstanceByID", ctx, id) ret0, _ := ret[0].(authz.Instance) ret1, _ := ret[1].(error) return ret0, ret1 } // InstanceByID indicates an expected call of InstanceByID. -func (mr *MockQueriesMockRecorder) InstanceByID(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) InstanceByID(ctx, id any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceByID", reflect.TypeOf((*MockQueries)(nil).InstanceByID), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceByID", reflect.TypeOf((*MockQueries)(nil).InstanceByID), ctx, id) } // MailTemplateByOrg mocks base method. -func (m *MockQueries) MailTemplateByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.MailTemplate, error) { +func (m *MockQueries) MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MailTemplateByOrg", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "MailTemplateByOrg", ctx, orgID, withOwnerRemoved) ret0, _ := ret[0].(*query.MailTemplate) ret1, _ := ret[1].(error) return ret0, ret1 } // MailTemplateByOrg indicates an expected call of MailTemplateByOrg. -func (mr *MockQueriesMockRecorder) MailTemplateByOrg(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) MailTemplateByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), ctx, orgID, withOwnerRemoved) } // NotificationPolicyByOrg mocks base method. -func (m *MockQueries) NotificationPolicyByOrg(arg0 context.Context, arg1 bool, arg2 string, arg3 bool) (*query.NotificationPolicy, error) { +func (m *MockQueries) NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationPolicyByOrg", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "NotificationPolicyByOrg", ctx, shouldTriggerBulk, orgID, withOwnerRemoved) ret0, _ := ret[0].(*query.NotificationPolicy) ret1, _ := ret[1].(error) return ret0, ret1 } // NotificationPolicyByOrg indicates an expected call of NotificationPolicyByOrg. -func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(ctx, shouldTriggerBulk, orgID, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), ctx, shouldTriggerBulk, orgID, withOwnerRemoved) } // NotificationProviderByIDAndType mocks base method. -func (m *MockQueries) NotificationProviderByIDAndType(arg0 context.Context, arg1 string, arg2 domain.NotificationProviderType) (*query.DebugNotificationProvider, error) { +func (m *MockQueries) NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", ctx, aggID, providerType) ret0, _ := ret[0].(*query.DebugNotificationProvider) ret1, _ := ret[1].(error) return ret0, ret1 } // NotificationProviderByIDAndType indicates an expected call of NotificationProviderByIDAndType. -func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(ctx, aggID, providerType any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), ctx, aggID, providerType) } // SMSProviderConfigActive mocks base method. -func (m *MockQueries) SMSProviderConfigActive(arg0 context.Context, arg1 string) (*query.SMSConfig, error) { +func (m *MockQueries) SMSProviderConfigActive(ctx context.Context, resourceOwner string) (*query.SMSConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SMSProviderConfigActive", arg0, arg1) + ret := m.ctrl.Call(m, "SMSProviderConfigActive", ctx, resourceOwner) ret0, _ := ret[0].(*query.SMSConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // SMSProviderConfigActive indicates an expected call of SMSProviderConfigActive. -func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(ctx, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), ctx, resourceOwner) } // SMTPConfigActive mocks base method. -func (m *MockQueries) SMTPConfigActive(arg0 context.Context, arg1 string) (*query.SMTPConfig, error) { +func (m *MockQueries) SMTPConfigActive(ctx context.Context, resourceOwner string) (*query.SMTPConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SMTPConfigActive", arg0, arg1) + ret := m.ctrl.Call(m, "SMTPConfigActive", ctx, resourceOwner) ret0, _ := ret[0].(*query.SMTPConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // SMTPConfigActive indicates an expected call of SMTPConfigActive. -func (mr *MockQueriesMockRecorder) SMTPConfigActive(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SMTPConfigActive(ctx, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigActive", reflect.TypeOf((*MockQueries)(nil).SMTPConfigActive), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigActive", reflect.TypeOf((*MockQueries)(nil).SMTPConfigActive), ctx, resourceOwner) } // SearchInstanceDomains mocks base method. -func (m *MockQueries) SearchInstanceDomains(arg0 context.Context, arg1 *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) { +func (m *MockQueries) SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchInstanceDomains", arg0, arg1) + ret := m.ctrl.Call(m, "SearchInstanceDomains", ctx, queries) ret0, _ := ret[0].(*query.InstanceDomains) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchInstanceDomains indicates an expected call of SearchInstanceDomains. -func (mr *MockQueriesMockRecorder) SearchInstanceDomains(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SearchInstanceDomains(ctx, queries any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), ctx, queries) } // SearchMilestones mocks base method. -func (m *MockQueries) SearchMilestones(arg0 context.Context, arg1 []string, arg2 *query.MilestonesSearchQueries) (*query.Milestones, error) { +func (m *MockQueries) SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchMilestones", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "SearchMilestones", ctx, instanceIDs, queries) ret0, _ := ret[0].(*query.Milestones) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchMilestones indicates an expected call of SearchMilestones. -func (mr *MockQueriesMockRecorder) SearchMilestones(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SearchMilestones(ctx, instanceIDs, queries any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), ctx, instanceIDs, queries) } // SessionByID mocks base method. -func (m *MockQueries) SessionByID(arg0 context.Context, arg1 bool, arg2, arg3 string, arg4 domain.PermissionCheck) (*query.Session, error) { +func (m *MockQueries) SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string, check domain.PermissionCheck) (*query.Session, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SessionByID", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "SessionByID", ctx, shouldTriggerBulk, id, sessionToken, check) ret0, _ := ret[0].(*query.Session) ret1, _ := ret[1].(error) return ret0, ret1 } // SessionByID indicates an expected call of SessionByID. -func (mr *MockQueriesMockRecorder) SessionByID(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SessionByID(ctx, shouldTriggerBulk, id, sessionToken, check any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), ctx, shouldTriggerBulk, id, sessionToken, check) } diff --git a/internal/notification/handlers/mock/queue.mock.go b/internal/notification/handlers/mock/queue.mock.go index e1387595db..e9cf3efed1 100644 --- a/internal/notification/handlers/mock/queue.mock.go +++ b/internal/notification/handlers/mock/queue.mock.go @@ -22,6 +22,7 @@ import ( type MockQueue struct { ctrl *gomock.Controller recorder *MockQueueMockRecorder + isgomock struct{} } // MockQueueMockRecorder is the mock recorder for MockQueue. @@ -42,10 +43,10 @@ func (m *MockQueue) EXPECT() *MockQueueMockRecorder { } // Insert mocks base method. -func (m *MockQueue) Insert(arg0 context.Context, arg1 river.JobArgs, arg2 ...queue.InsertOpt) error { +func (m *MockQueue) Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, args} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Insert", varargs...) @@ -54,8 +55,8 @@ func (m *MockQueue) Insert(arg0 context.Context, arg1 river.JobArgs, arg2 ...que } // Insert indicates an expected call of Insert. -func (mr *MockQueueMockRecorder) Insert(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockQueueMockRecorder) Insert(ctx, args any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, args}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockQueue)(nil).Insert), varargs...) } diff --git a/internal/notification/handlers/queries.go b/internal/notification/handlers/queries.go index a3d68e4797..d9ff1b4201 100644 --- a/internal/notification/handlers/queries.go +++ b/internal/notification/handlers/queries.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "time" "github.com/go-jose/go-jose/v4" "golang.org/x/text/language" @@ -30,7 +29,6 @@ type Queries interface { GetInstanceRestrictions(ctx context.Context) (restrictions query.Restrictions, err error) InstanceByID(ctx context.Context, id string) (instance authz.Instance, err error) GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) - ActivePrivateSigningKey(ctx context.Context, t time.Time) (keys *query.PrivateKeys, err error) ActiveInstances() []string } diff --git a/internal/project/model/oidc_config.go b/internal/project/model/oidc_config.go index 50be6c318a..2c482a67a7 100644 --- a/internal/project/model/oidc_config.go +++ b/internal/project/model/oidc_config.go @@ -3,6 +3,8 @@ package model import ( "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -98,7 +100,7 @@ func GetOIDCCompliance(version OIDCVersion, appType OIDCApplicationType, grantTy for i, grantType := range grantTypes { domainGrantTypes[i] = domain.OIDCGrantType(grantType) } - compliance := domain.GetOIDCV1Compliance(domain.OIDCApplicationType(appType), domainGrantTypes, domain.OIDCAuthMethodType(authMethod), redirectUris) + compliance := domain.GetOIDCV1Compliance(gu.Ptr(domain.OIDCApplicationType(appType)), domainGrantTypes, gu.Ptr(domain.OIDCAuthMethodType(authMethod)), redirectUris) return &Compliance{ NoneCompliant: compliance.NoneCompliant, Problems: compliance.Problems, diff --git a/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl b/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl index adb71c42ff..0fb1c3e102 100644 --- a/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl +++ b/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl @@ -5,6 +5,7 @@ package {{.GoPackageName}} import ( "github.com/zitadel/zitadel/internal/api/authz" {{if .AuthContext}}"github.com/zitadel/zitadel/internal/api/grpc/server/middleware"{{end}} + {{if .AuthContext}}"github.com/zitadel/zitadel/internal/api/grpc/server/connect_middleware"{{end}} ) var {{.ServiceName}}_AuthMethods = authz.MethodMapping { @@ -23,6 +24,13 @@ func (r *{{ $m.Name }}) OrganizationFromRequest() *middleware.Organization { Domain: r{{$m.OrgMethod}}.GetOrgDomain(), } } + +func (r *{{ $m.Name }}) OrganizationFromRequestConnect() *connect_middleware.Organization { + return &connect_middleware.Organization{ + ID: r{{$m.OrgMethod}}.GetOrgId(), + Domain: r{{$m.OrgMethod}}.GetOrgDomain(), + } +} {{ end }} {{ range $resp := .CustomHTTPResponses}} diff --git a/internal/query/administrators.go b/internal/query/administrators.go new file mode 100644 index 0000000000..9ab81993ef --- /dev/null +++ b/internal/query/administrators.go @@ -0,0 +1,347 @@ +package query + +import ( + "context" + "database/sql" + "slices" + "time" + + sq "github.com/Masterminds/squirrel" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type Administrators struct { + SearchResponse + Administrators []*Administrator +} + +type Administrator struct { + Roles database.TextArray[string] + CreationDate time.Time + ChangeDate time.Time + ResourceOwner string + + User *UserAdministrator + Org *OrgAdministrator + Instance *InstanceAdministrator + Project *ProjectAdministrator + ProjectGrant *ProjectGrantAdministrator +} + +type UserAdministrator struct { + UserID string + LoginName string + DisplayName string + ResourceOwner string +} +type OrgAdministrator struct { + OrgID string + Name string +} + +type InstanceAdministrator struct { + InstanceID string + Name string +} + +type ProjectAdministrator struct { + ProjectID string + Name string + ResourceOwner string +} + +type ProjectGrantAdministrator struct { + ProjectID string + ProjectName string + GrantID string + GrantedOrgID string + ResourceOwner string +} + +func NewAdministratorUserResourceOwnerSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(UserResourceOwnerCol, value, TextEquals) +} + +func NewAdministratorUserLoginNameSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(LoginNameNameCol, value, TextEquals) +} + +func NewAdministratorUserDisplayNameSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(HumanDisplayNameCol, value, TextEquals) +} + +func administratorInstancePermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + InstanceMemberResourceOwner, + domain.PermissionInstanceMemberRead, + OwnedRowsPermissionOption(InstanceMemberUserID), + ) + return query.JoinClause(join, args...) +} + +func administratorOrgPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + OrgMemberResourceOwner, + domain.PermissionOrgMemberRead, + OwnedRowsPermissionOption(OrgMemberUserID), + ) + return query.JoinClause(join, args...) +} + +func administratorProjectPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + ProjectMemberResourceOwner, + domain.PermissionProjectMemberRead, + WithProjectsPermissionOption(ProjectMemberProjectID), + OwnedRowsPermissionOption(ProjectMemberUserID), + ) + return query.JoinClause(join, args...) +} + +func administratorProjectGrantPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + ProjectGrantMemberResourceOwner, + domain.PermissionProjectGrantMemberRead, + WithProjectsPermissionOption(ProjectMemberProjectID), + OwnedRowsPermissionOption(ProjectGrantMemberUserID), + ) + return query.JoinClause(join, args...) +} + +func administratorsCheckPermission(ctx context.Context, administrators *Administrators, permissionCheck domain.PermissionCheck) { + selfUserID := authz.GetCtxData(ctx).UserID + administrators.Administrators = slices.DeleteFunc(administrators.Administrators, + func(administrator *Administrator) bool { + if administrator.User != nil && administrator.User.UserID == selfUserID { + return false + } + if administrator.ProjectGrant != nil { + return administratorProjectGrantCheckPermission(ctx, administrator.ProjectGrant.ResourceOwner, administrator.ProjectGrant.ProjectID, administrator.ProjectGrant.GrantID, administrator.ProjectGrant.GrantedOrgID, permissionCheck) != nil + } + if administrator.Project != nil { + return permissionCheck(ctx, domain.PermissionProjectMemberRead, administrator.Project.ResourceOwner, administrator.Project.ProjectID) != nil + } + if administrator.Org != nil { + return permissionCheck(ctx, domain.PermissionOrgMemberRead, administrator.Org.OrgID, administrator.Org.OrgID) != nil + } + if administrator.Instance != nil { + return permissionCheck(ctx, domain.PermissionInstanceMemberRead, administrator.Instance.InstanceID, administrator.Instance.InstanceID) != nil + } + return true + }, + ) +} + +func administratorProjectGrantCheckPermission(ctx context.Context, resourceOwner, projectID, grantID, grantedOrgID string, permissionCheck domain.PermissionCheck) error { + if err := permissionCheck(ctx, domain.PermissionProjectGrantMemberRead, resourceOwner, grantID); err != nil { + if err := permissionCheck(ctx, domain.PermissionProjectGrantMemberRead, grantedOrgID, grantID); err != nil { + if err := permissionCheck(ctx, domain.PermissionProjectGrantMemberRead, resourceOwner, projectID); err != nil { + return err + } + } + } + return nil +} + +func (q *Queries) SearchAdministrators(ctx context.Context, queries *MembershipSearchQuery, permissionCheck domain.PermissionCheck) (*Administrators, error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + admins, err := q.searchAdministrators(ctx, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + administratorsCheckPermission(ctx, admins, permissionCheck) + } + return admins, nil +} + +func (q *Queries) searchAdministrators(ctx context.Context, queries *MembershipSearchQuery, permissionCheckV2 bool) (administrators *Administrators, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, queryArgs, scan := prepareAdministratorsQuery(ctx, queries, permissionCheckV2) + eq := sq.Eq{membershipInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} + stmt, args, err := queries.toQuery(query).Where(eq).ToSql() + if err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "QUERY-TODO", "Errors.Query.InvalidRequest") + } + latestState, err := q.latestState(ctx, orgMemberTable, instanceMemberTable, projectMemberTable, projectGrantMemberTable) + if err != nil { + return nil, err + } + queryArgs = append(queryArgs, args...) + + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + administrators, err = scan(rows) + return err + }, stmt, queryArgs...) + if err != nil { + return nil, err + } + administrators.State = latestState + return administrators, nil +} + +func prepareAdministratorsQuery(ctx context.Context, queries *MembershipSearchQuery, permissionV2 bool) (sq.SelectBuilder, []interface{}, func(*sql.Rows) (*Administrators, error)) { + query, args := getMembershipFromQuery(ctx, queries, permissionV2) + return sq.Select( + MembershipUserID.identifier(), + membershipRoles.identifier(), + MembershipCreationDate.identifier(), + MembershipChangeDate.identifier(), + membershipResourceOwner.identifier(), + membershipOrgID.identifier(), + membershipIAMID.identifier(), + membershipProjectID.identifier(), + membershipGrantID.identifier(), + ProjectGrantColumnGrantedOrgID.identifier(), + ProjectColumnResourceOwner.identifier(), + ProjectColumnName.identifier(), + OrgColumnName.identifier(), + InstanceColumnName.identifier(), + LoginNameNameCol.identifier(), + HumanDisplayNameCol.identifier(), + MachineNameCol.identifier(), + HumanAvatarURLCol.identifier(), + UserTypeCol.identifier(), + UserResourceOwnerCol.identifier(), + countColumn.identifier(), + ).From(query). + LeftJoin(join(ProjectColumnID, membershipProjectID)). + LeftJoin(join(ProjectGrantColumnGrantID, membershipGrantID)). + LeftJoin(join(OrgColumnID, membershipOrgID)). + LeftJoin(join(InstanceColumnID, membershipInstanceID)). + LeftJoin(join(HumanUserIDCol, OrgMemberUserID)). + LeftJoin(join(MachineUserIDCol, OrgMemberUserID)). + LeftJoin(join(UserIDCol, OrgMemberUserID)). + LeftJoin(join(LoginNameUserIDCol, OrgMemberUserID)). + Where( + sq.Eq{LoginNameIsPrimaryCol.identifier(): true}, + ).PlaceholderFormat(sq.Dollar), + args, + func(rows *sql.Rows) (*Administrators, error) { + administrators := make([]*Administrator, 0) + var count uint64 + for rows.Next() { + + var ( + administrator = new(Administrator) + userID = sql.NullString{} + orgID = sql.NullString{} + instanceID = sql.NullString{} + projectID = sql.NullString{} + grantID = sql.NullString{} + grantedOrgID = sql.NullString{} + projectName = sql.NullString{} + orgName = sql.NullString{} + instanceName = sql.NullString{} + projectResourceOwner = sql.NullString{} + loginName = sql.NullString{} + displayName = sql.NullString{} + machineName = sql.NullString{} + avatarURL = sql.NullString{} + userType = sql.NullInt32{} + userResourceOwner = sql.NullString{} + ) + + err := rows.Scan( + &userID, + &administrator.Roles, + &administrator.CreationDate, + &administrator.ChangeDate, + &administrator.ResourceOwner, + &orgID, + &instanceID, + &projectID, + &grantID, + &grantedOrgID, + &projectResourceOwner, + &projectName, + &orgName, + &instanceName, + &loginName, + &displayName, + &machineName, + &avatarURL, + &userType, + &userResourceOwner, + &count, + ) + + if err != nil { + return nil, err + } + + if userID.Valid { + administrator.User = &UserAdministrator{ + UserID: userID.String, + LoginName: loginName.String, + DisplayName: displayName.String, + ResourceOwner: userResourceOwner.String, + } + } + + if orgID.Valid { + administrator.Org = &OrgAdministrator{ + OrgID: orgID.String, + Name: orgName.String, + } + } + if instanceID.Valid { + administrator.Instance = &InstanceAdministrator{ + InstanceID: instanceID.String, + Name: instanceName.String, + } + } + if projectID.Valid && grantID.Valid && grantedOrgID.Valid { + administrator.ProjectGrant = &ProjectGrantAdministrator{ + ProjectID: projectID.String, + ProjectName: projectName.String, + GrantID: grantID.String, + GrantedOrgID: grantedOrgID.String, + ResourceOwner: projectResourceOwner.String, + } + } else if projectID.Valid { + administrator.Project = &ProjectAdministrator{ + ProjectID: projectID.String, + Name: projectName.String, + ResourceOwner: projectResourceOwner.String, + } + } + + administrators = append(administrators, administrator) + } + + if err := rows.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-TODO", "Errors.Query.CloseRows") + } + + return &Administrators{ + Administrators: administrators, + SearchResponse: SearchResponse{ + Count: count, + }, + }, nil + } +} diff --git a/internal/query/app.go b/internal/query/app.go index 5fed1e3ced..777c295139 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -5,9 +5,11 @@ import ( "database/sql" _ "embed" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" + "github.com/muhlemmer/gu" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -307,6 +309,19 @@ func (q *Queries) AppByProjectAndAppID(ctx context.Context, shouldTriggerBulk bo return app, err } +func (q *Queries) AppByIDWithPermission(ctx context.Context, appID string, activeOnly bool, permissionCheck domain.PermissionCheck) (*App, error) { + app, err := q.AppByID(ctx, appID, activeOnly) + if err != nil { + return nil, err + } + + if err := appCheckPermission(ctx, app.ResourceOwner, app.ProjectID, permissionCheck); err != nil { + return nil, err + } + + return app, nil +} + func (q *Queries) AppByID(ctx context.Context, appID string, activeOnly bool) (app *App, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -455,27 +470,6 @@ func (q *Queries) ProjectIDFromClientID(ctx context.Context, appID string) (id s return id, err } -func (q *Queries) ProjectByOIDCClientID(ctx context.Context, id string) (project *Project, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := prepareProjectByOIDCAppQuery() - eq := sq.Eq{ - AppOIDCConfigColumnClientID.identifier(): id, - AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - } - query, args, err := stmt.Where(eq).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-XhJi4", "Errors.Query.SQLStatement") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - project, err = scan(row) - return err - }, query, args...) - return project, err -} - func (q *Queries) AppByOIDCClientID(ctx context.Context, clientID string) (app *App, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -526,11 +520,25 @@ func (q *Queries) AppByClientID(ctx context.Context, clientID string) (app *App, return app, err } -func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, withOwnerRemoved bool) (apps *Apps, err error) { +func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, permissionCheck domain.PermissionCheck) (*Apps, error) { + apps, err := q.searchApps(ctx, queries, PermissionV2(ctx, permissionCheck)) + if err != nil { + return nil, err + } + + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + apps.Apps = appsCheckPermission(ctx, apps.Apps, permissionCheck) + } + return apps, nil +} + +func (q *Queries) searchApps(ctx context.Context, queries *AppSearchQueries, isPermissionV2Enabled bool) (apps *Apps, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareAppsQuery() + query = appPermissionCheckV2(ctx, query, isPermissionV2Enabled, queries) + eq := sq.Eq{AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { @@ -548,6 +556,21 @@ func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, wit return apps, err } +func appPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *AppSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + + join, args := PermissionClause( + ctx, + AppColumnResourceOwner, + domain.PermissionProjectAppRead, + SingleOrgPermissionOption(queries.Queries), + WithProjectsPermissionOption(AppColumnProjectID), + ) + return query.JoinClause(join, args...) +} + func (q *Queries) SearchClientIDs(ctx context.Context, queries *AppSearchQueries, shouldTriggerBulk bool) (ids []string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -624,10 +647,25 @@ func (q *Queries) SAMLAppLoginVersion(ctx context.Context, appID string) (loginV return loginVersion, nil } +func appCheckPermission(ctx context.Context, resourceOwner string, projectID string, permissionCheck domain.PermissionCheck) error { + return permissionCheck(ctx, domain.PermissionProjectAppRead, resourceOwner, projectID) +} + +// appsCheckPermission returns only the apps that the user in context has permission to read +func appsCheckPermission(ctx context.Context, apps []*App, permissionCheck domain.PermissionCheck) []*App { + return slices.DeleteFunc(apps, func(app *App) bool { + return permissionCheck(ctx, domain.PermissionProjectAppRead, app.ResourceOwner, app.ProjectID) != nil + }) +} + func NewAppNameSearchQuery(method TextComparison, value string) (SearchQuery, error) { return NewTextQuery(AppColumnName, value, method) } +func NewAppStateSearchQuery(value domain.AppState) (SearchQuery, error) { + return NewNumberQuery(AppColumnState, int(value), NumberEquals) +} + func NewAppProjectIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(AppColumnProjectID, id, TextEquals) } @@ -867,48 +905,6 @@ func prepareProjectIDByAppQuery() (sq.SelectBuilder, func(*sql.Row) (projectID s } } -func prepareProjectByOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*Project, error)) { - return sq.Select( - ProjectColumnID.identifier(), - ProjectColumnCreationDate.identifier(), - ProjectColumnChangeDate.identifier(), - ProjectColumnResourceOwner.identifier(), - ProjectColumnState.identifier(), - ProjectColumnSequence.identifier(), - ProjectColumnName.identifier(), - ProjectColumnProjectRoleAssertion.identifier(), - ProjectColumnProjectRoleCheck.identifier(), - ProjectColumnHasProjectCheck.identifier(), - ProjectColumnPrivateLabelingSetting.identifier(), - ).From(projectsTable.identifier()). - Join(join(AppColumnProjectID, ProjectColumnID)). - Join(join(AppOIDCConfigColumnAppID, AppColumnID)). - PlaceholderFormat(sq.Dollar), - func(row *sql.Row) (*Project, error) { - p := new(Project) - err := row.Scan( - &p.ID, - &p.CreationDate, - &p.ChangeDate, - &p.ResourceOwner, - &p.State, - &p.Sequence, - &p.Name, - &p.ProjectRoleAssertion, - &p.ProjectRoleCheck, - &p.HasProjectCheck, - &p.PrivateLabelingSetting, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, zerrors.ThrowNotFound(err, "QUERY-yxTMh", "Errors.Project.NotFound") - } - return nil, zerrors.ThrowInternal(err, "QUERY-dj2FF", "Errors.Internal") - } - return p, nil - } -} - func prepareProjectByAppQuery() (sq.SelectBuilder, func(*sql.Row) (*Project, error)) { return sq.Select( ProjectColumnID.identifier(), @@ -1181,7 +1177,7 @@ func (c sqlOIDCConfig) set(app *App) { if c.loginBaseURI.Valid { app.OIDCConfig.LoginBaseURI = &c.loginBaseURI.String } - compliance := domain.GetOIDCCompliance(app.OIDCConfig.Version, app.OIDCConfig.AppType, app.OIDCConfig.GrantTypes, app.OIDCConfig.ResponseTypes, app.OIDCConfig.AuthMethodType, app.OIDCConfig.RedirectURIs) + compliance := domain.GetOIDCCompliance(gu.Ptr(app.OIDCConfig.Version), gu.Ptr(app.OIDCConfig.AppType), app.OIDCConfig.GrantTypes, app.OIDCConfig.ResponseTypes, gu.Ptr(app.OIDCConfig.AuthMethodType), app.OIDCConfig.RedirectURIs) app.OIDCConfig.ComplianceProblems = compliance.Problems var err error diff --git a/internal/query/authn_key.go b/internal/query/authn_key.go index ffbe38e7ae..abda5f011e 100644 --- a/internal/query/authn_key.go +++ b/internal/query/authn_key.go @@ -98,6 +98,7 @@ type AuthNKey struct { ChangeDate time.Time ResourceOwner string Sequence uint64 + ApplicationID string Expiration time.Time Type domain.AuthNKeyType @@ -222,6 +223,19 @@ func (q *Queries) SearchAuthNKeysData(ctx context.Context, queries *AuthNKeySear return authNKeys, err } +func (q *Queries) GetAuthNKeyByIDWithPermission(ctx context.Context, shouldTriggerBulk bool, id string, permissionCheck domain.PermissionCheck, queries ...SearchQuery) (*AuthNKey, error) { + key, err := q.GetAuthNKeyByID(ctx, shouldTriggerBulk, id, queries...) + if err != nil { + return nil, err + } + + if err := appCheckPermission(ctx, key.ResourceOwner, key.AggregateID, permissionCheck); err != nil { + return nil, err + } + + return key, nil +} + func (q *Queries) GetAuthNKeyByID(ctx context.Context, shouldTriggerBulk bool, id string, queries ...SearchQuery) (key *AuthNKey, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -254,34 +268,6 @@ func (q *Queries) GetAuthNKeyByID(ctx context.Context, shouldTriggerBulk bool, i return key, err } -func (q *Queries) GetAuthNKeyPublicKeyByIDAndIdentifier(ctx context.Context, id string, identifier string) (key []byte, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := prepareAuthNKeyPublicKeyQuery() - eq := sq.And{ - sq.Eq{ - AuthNKeyColumnID.identifier(): id, - AuthNKeyColumnIdentifier.identifier(): identifier, - AuthNKeyColumnEnabled.identifier(): true, - AuthNKeyColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - }, - sq.Gt{ - AuthNKeyColumnExpiration.identifier(): time.Now(), - }, - } - query, args, err := stmt.Where(eq).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-DAb32", "Errors.Query.SQLStatement") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - key, err = scan(row) - return err - }, query, args...) - return key, err -} - func NewAuthNKeyResourceOwnerQuery(id string) (SearchQuery, error) { return NewTextQuery(AuthNKeyColumnResourceOwner, id, TextEquals) } @@ -358,6 +344,7 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys AuthNKeyColumnSequence.identifier(), AuthNKeyColumnExpiration.identifier(), AuthNKeyColumnType.identifier(), + AuthNKeyColumnObjectID.identifier(), countColumn.identifier(), ).From(authNKeyTable.identifier()). PlaceholderFormat(sq.Dollar) @@ -376,6 +363,7 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys &authNKey.Sequence, &authNKey.Expiration, &authNKey.Type, + &authNKey.ApplicationID, &count, ) if err != nil { @@ -429,26 +417,6 @@ func prepareAuthNKeyQuery() (sq.SelectBuilder, func(row *sql.Row) (*AuthNKey, er } } -func prepareAuthNKeyPublicKeyQuery() (sq.SelectBuilder, func(row *sql.Row) ([]byte, error)) { - return sq.Select( - AuthNKeyColumnPublicKey.identifier(), - ).From(authNKeyTable.identifier()). - PlaceholderFormat(sq.Dollar), - func(row *sql.Row) ([]byte, error) { - var publicKey []byte - err := row.Scan( - &publicKey, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, zerrors.ThrowNotFound(err, "QUERY-SDf32", "Errors.AuthNKey.NotFound") - } - return nil, zerrors.ThrowInternal(err, "QUERY-Bfs2a", "Errors.Internal") - } - return publicKey, nil - } -} - func prepareAuthNKeysDataQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeysData, error)) { return sq.Select( AuthNKeyColumnID.identifier(), diff --git a/internal/query/authn_key_test.go b/internal/query/authn_key_test.go index b7c66cc665..619ffaac8c 100644 --- a/internal/query/authn_key_test.go +++ b/internal/query/authn_key_test.go @@ -26,6 +26,7 @@ var ( ` projections.authn_keys2.sequence,` + ` projections.authn_keys2.expiration,` + ` projections.authn_keys2.type,` + + ` projections.authn_keys2.object_id,` + ` COUNT(*) OVER ()` + ` FROM projections.authn_keys2` prepareAuthNKeysCols = []string{ @@ -37,6 +38,7 @@ var ( "sequence", "expiration", "type", + "object_id", "count", } @@ -129,6 +131,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { uint64(20211109), testNow, 1, + "app1", }, }, ), @@ -147,6 +150,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { Sequence: 20211109, Expiration: testNow, Type: domain.AuthNKeyTypeJSON, + ApplicationID: "app1", }, }, }, @@ -168,6 +172,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { uint64(20211109), testNow, 1, + "app1", }, { "id-2", @@ -178,6 +183,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { uint64(20211109), testNow, 1, + "app1", }, }, ), @@ -196,6 +202,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { Sequence: 20211109, Expiration: testNow, Type: domain.AuthNKeyTypeJSON, + ApplicationID: "app1", }, { ID: "id-2", @@ -206,6 +213,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { Sequence: 20211109, Expiration: testNow, Type: domain.AuthNKeyTypeJSON, + ApplicationID: "app1", }, }, }, @@ -423,55 +431,6 @@ func Test_AuthNKeyPrepares(t *testing.T) { }, object: (*AuthNKey)(nil), }, - { - name: "prepareAuthNKeyPublicKeyQuery no result", - prepare: prepareAuthNKeyPublicKeyQuery, - want: want{ - sqlExpectations: mockQueriesScanErr( - regexp.QuoteMeta(prepareAuthNKeyPublicKeyStmt), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: ([]byte)(nil), - }, - { - name: "prepareAuthNKeyPublicKeyQuery found", - prepare: prepareAuthNKeyPublicKeyQuery, - want: want{ - sqlExpectations: mockQuery( - regexp.QuoteMeta(prepareAuthNKeyPublicKeyStmt), - prepareAuthNKeyPublicKeyCols, - []driver.Value{ - []byte("publicKey"), - }, - ), - }, - object: []byte("publicKey"), - }, - { - name: "prepareAuthNKeyPublicKeyQuery sql err", - prepare: prepareAuthNKeyPublicKeyQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(prepareAuthNKeyPublicKeyStmt), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: ([]byte)(nil), - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 4ec40dc9d5..73f51bfdf7 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -8,21 +8,18 @@ import ( ) type InstanceFeatures struct { - Details *domain.ObjectDetails - LoginDefaultOrg FeatureSource[bool] - TriggerIntrospectionProjections FeatureSource[bool] - LegacyIntrospection FeatureSource[bool] - UserSchema FeatureSource[bool] - TokenExchange FeatureSource[bool] - ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] - WebKey FeatureSource[bool] - DebugOIDCParentError FeatureSource[bool] - OIDCSingleV1SessionTermination FeatureSource[bool] - DisableUserTokenEvent FeatureSource[bool] - EnableBackChannelLogout FeatureSource[bool] - LoginV2 FeatureSource[*feature.LoginV2] - PermissionCheckV2 FeatureSource[bool] - ConsoleUseV2UserApi FeatureSource[bool] + Details *domain.ObjectDetails + LoginDefaultOrg FeatureSource[bool] + UserSchema FeatureSource[bool] + TokenExchange FeatureSource[bool] + ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] + DebugOIDCParentError FeatureSource[bool] + OIDCSingleV1SessionTermination FeatureSource[bool] + DisableUserTokenEvent FeatureSource[bool] + EnableBackChannelLogout FeatureSource[bool] + LoginV2 FeatureSource[*feature.LoginV2] + PermissionCheckV2 FeatureSource[bool] + ConsoleUseV2UserApi FeatureSource[bool] } func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) { diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index 6a0abbb58c..fa0f638bed 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -63,12 +63,9 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v1.DefaultLoginInstanceEventType, feature_v2.InstanceResetEventType, feature_v2.InstanceLoginDefaultOrgEventType, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, - feature_v2.InstanceLegacyIntrospectionEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, - feature_v2.InstanceWebKeyEventType, feature_v2.InstanceDebugOIDCParentErrorEventType, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, feature_v2.InstanceDisableUserTokenEvent, @@ -93,8 +90,6 @@ func (m *InstanceFeaturesReadModel) populateFromSystem() bool { return false } m.instance.LoginDefaultOrg = m.system.LoginDefaultOrg - m.instance.TriggerIntrospectionProjections = m.system.TriggerIntrospectionProjections - m.instance.LegacyIntrospection = m.system.LegacyIntrospection m.instance.UserSchema = m.system.UserSchema m.instance.TokenExchange = m.system.TokenExchange m.instance.ImprovedPerformance = m.system.ImprovedPerformance @@ -111,23 +106,16 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ return err } switch key { - case feature.KeyUnspecified, - feature.KeyActionsDeprecated: + case feature.KeyUnspecified: return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) - case feature.KeyTriggerIntrospectionProjections: - features.TriggerIntrospectionProjections.set(level, event.Value) - case feature.KeyLegacyIntrospection: - features.LegacyIntrospection.set(level, event.Value) case feature.KeyUserSchema: features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: features.TokenExchange.set(level, event.Value) case feature.KeyImprovedPerformance: features.ImprovedPerformance.set(level, event.Value) - case feature.KeyWebKey: - features.WebKey.set(level, event.Value) case feature.KeyDebugOIDCParentError: features.DebugOIDCParentError.set(level, event.Value) case feature.KeyOIDCSingleV1SessionTermination: diff --git a/internal/query/instance_features_test.go b/internal/query/instance_features_test.go index d80a3b05fc..5c5f8ecc64 100644 --- a/internal/query/instance_features_test.go +++ b/internal/query/instance_features_test.go @@ -71,14 +71,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, }, }, { @@ -93,14 +85,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -116,14 +100,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelInstance, Value: false, @@ -142,14 +118,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -158,10 +126,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), ), ), args: args{true}, @@ -173,14 +137,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, @@ -195,14 +151,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -211,10 +159,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), ), ), args: args{false}, @@ -226,14 +170,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, diff --git a/internal/query/introspection.go b/internal/query/introspection.go index ee96bf576b..a3ef125466 100644 --- a/internal/query/introspection.go +++ b/internal/query/introspection.go @@ -25,12 +25,6 @@ var introspectionTriggerHandlers = sync.OnceValue(func() []*handler.Handler { ) }) -// TriggerIntrospectionProjections triggers all projections -// relevant to introspection queries concurrently. -func TriggerIntrospectionProjections(ctx context.Context) { - triggerBatch(ctx, introspectionTriggerHandlers()...) -} - type AppType string const ( diff --git a/internal/query/key.go b/internal/query/key.go index 4831d88654..e7b81bb951 100644 --- a/internal/query/key.go +++ b/internal/query/key.go @@ -1,20 +1,10 @@ package query import ( - "context" - "crypto/rsa" - "database/sql" "time" - sq "github.com/Masterminds/squirrel" - - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query/projection" - "github.com/zitadel/zitadel/internal/repository/keypair" - "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" ) type Key interface { @@ -36,11 +26,6 @@ type PublicKey interface { Key() interface{} } -type PrivateKeys struct { - SearchResponse - Keys []PrivateKey -} - type PublicKeys struct { SearchResponse Keys []PublicKey @@ -72,34 +57,6 @@ func (k *key) Sequence() uint64 { return k.sequence } -type privateKey struct { - key - expiry time.Time - privateKey *crypto.CryptoValue -} - -func (k *privateKey) Expiry() time.Time { - return k.expiry -} - -func (k *privateKey) Key() *crypto.CryptoValue { - return k.privateKey -} - -type rsaPublicKey struct { - key - expiry time.Time - publicKey *rsa.PublicKey -} - -func (r *rsaPublicKey) Expiry() time.Time { - return r.expiry -} - -func (r *rsaPublicKey) Key() interface{} { - return r.publicKey -} - var ( keyTable = table{ name: projection.KeyProjectionTable, @@ -157,277 +114,3 @@ var ( table: keyPrivateTable, } ) - -var ( - keyPublicTable = table{ - name: projection.KeyPublicTable, - instanceIDCol: projection.KeyPrivateColumnInstanceID, - } - KeyPublicColID = Column{ - name: projection.KeyPublicColumnID, - table: keyPublicTable, - } - KeyPublicColExpiry = Column{ - name: projection.KeyPublicColumnExpiry, - table: keyPublicTable, - } - KeyPublicColKey = Column{ - name: projection.KeyPublicColumnKey, - table: keyPublicTable, - } -) - -func (q *Queries) ActivePublicKeys(ctx context.Context, t time.Time) (keys *PublicKeys, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - query, scan := preparePublicKeysQuery() - if t.IsZero() { - t = time.Now() - } - stmt, args, err := query.Where( - sq.And{ - sq.Eq{KeyColInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()}, - sq.Gt{KeyPublicColExpiry.identifier(): t}, - }).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-SDFfg", "Errors.Query.SQLStatement") - } - - err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { - keys, err = scan(rows) - return err - }, stmt, args...) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Sghn4", "Errors.Internal") - } - - keys.State, err = q.latestState(ctx, keyTable) - if !zerrors.IsNotFound(err) { - return keys, err - } - return keys, nil -} - -func (q *Queries) ActivePrivateSigningKey(ctx context.Context, t time.Time) (keys *PrivateKeys, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := preparePrivateKeysQuery() - if t.IsZero() { - t = time.Now() - } - query, args, err := stmt.Where( - sq.And{ - sq.Eq{ - KeyColUse.identifier(): crypto.KeyUsageSigning, - KeyColInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - }, - sq.Gt{KeyPrivateColExpiry.identifier(): t}, - }).OrderBy(KeyPrivateColExpiry.identifier()).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-SDff2", "Errors.Query.SQLStatement") - } - - err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { - keys, err = scan(rows) - return err - }, query, args...) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-WRFG4", "Errors.Internal") - } - keys.State, err = q.latestState(ctx, keyTable) - if !zerrors.IsNotFound(err) { - return keys, err - } - return keys, nil -} - -func preparePublicKeysQuery() (sq.SelectBuilder, func(*sql.Rows) (*PublicKeys, error)) { - return sq.Select( - KeyColID.identifier(), - KeyColCreationDate.identifier(), - KeyColChangeDate.identifier(), - KeyColSequence.identifier(), - KeyColResourceOwner.identifier(), - KeyColAlgorithm.identifier(), - KeyColUse.identifier(), - KeyPublicColExpiry.identifier(), - KeyPublicColKey.identifier(), - countColumn.identifier(), - ).From(keyTable.identifier()). - LeftJoin(join(KeyPublicColID, KeyColID)). - PlaceholderFormat(sq.Dollar), - func(rows *sql.Rows) (*PublicKeys, error) { - keys := make([]PublicKey, 0) - var count uint64 - for rows.Next() { - k := new(rsaPublicKey) - var keyValue []byte - err := rows.Scan( - &k.id, - &k.creationDate, - &k.changeDate, - &k.sequence, - &k.resourceOwner, - &k.algorithm, - &k.use, - &k.expiry, - &keyValue, - &count, - ) - if err != nil { - return nil, err - } - k.publicKey, err = crypto.BytesToPublicKey(keyValue) - if err != nil { - return nil, err - } - keys = append(keys, k) - } - - if err := rows.Close(); err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-rKd6k", "Errors.Query.CloseRows") - } - - return &PublicKeys{ - Keys: keys, - SearchResponse: SearchResponse{ - Count: count, - }, - }, nil - } -} - -func preparePrivateKeysQuery() (sq.SelectBuilder, func(*sql.Rows) (*PrivateKeys, error)) { - return sq.Select( - KeyColID.identifier(), - KeyColCreationDate.identifier(), - KeyColChangeDate.identifier(), - KeyColSequence.identifier(), - KeyColResourceOwner.identifier(), - KeyColAlgorithm.identifier(), - KeyColUse.identifier(), - KeyPrivateColExpiry.identifier(), - KeyPrivateColKey.identifier(), - countColumn.identifier(), - ).From(keyTable.identifier()). - LeftJoin(join(KeyPrivateColID, KeyColID)). - PlaceholderFormat(sq.Dollar), - func(rows *sql.Rows) (*PrivateKeys, error) { - keys := make([]PrivateKey, 0) - var count uint64 - for rows.Next() { - k := new(privateKey) - err := rows.Scan( - &k.id, - &k.creationDate, - &k.changeDate, - &k.sequence, - &k.resourceOwner, - &k.algorithm, - &k.use, - &k.expiry, - &k.privateKey, - &count, - ) - if err != nil { - return nil, err - } - keys = append(keys, k) - } - - if err := rows.Close(); err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-rKd6k", "Errors.Query.CloseRows") - } - - return &PrivateKeys{ - Keys: keys, - SearchResponse: SearchResponse{ - Count: count, - }, - }, nil - } -} - -type PublicKeyReadModel struct { - eventstore.ReadModel - - Algorithm string - Key *crypto.CryptoValue - Expiry time.Time - Usage crypto.KeyUsage -} - -func NewPublicKeyReadModel(keyID, resourceOwner string) *PublicKeyReadModel { - return &PublicKeyReadModel{ - ReadModel: eventstore.ReadModel{ - AggregateID: keyID, - ResourceOwner: resourceOwner, - }, - } -} - -func (wm *PublicKeyReadModel) AppendEvents(events ...eventstore.Event) { - wm.ReadModel.AppendEvents(events...) -} - -func (wm *PublicKeyReadModel) Reduce() error { - for _, event := range wm.Events { - switch e := event.(type) { - case *keypair.AddedEvent: - wm.Algorithm = e.Algorithm - wm.Key = e.PublicKey.Key - wm.Expiry = e.PublicKey.Expiry - wm.Usage = e.Usage - default: - } - } - return wm.ReadModel.Reduce() -} - -func (wm *PublicKeyReadModel) Query() *eventstore.SearchQueryBuilder { - return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). - AwaitOpenTransactions(). - ResourceOwner(wm.ResourceOwner). - AddQuery(). - AggregateTypes(keypair.AggregateType). - AggregateIDs(wm.AggregateID). - EventTypes(keypair.AddedEventType). - Builder() -} - -func (q *Queries) GetPublicKeyByID(ctx context.Context, keyID string) (_ PublicKey, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - model := NewPublicKeyReadModel(keyID, authz.GetInstance(ctx).InstanceID()) - if err := q.eventstore.FilterToQueryReducer(ctx, model); err != nil { - return nil, err - } - if model.Algorithm == "" || model.Key == nil { - return nil, zerrors.ThrowNotFound(err, "QUERY-Ahf7x", "Errors.Key.NotFound") - } - keyValue, err := crypto.Decrypt(model.Key, q.keyEncryptionAlgorithm) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Ie4oh", "Errors.Internal") - } - publicKey, err := crypto.BytesToPublicKey(keyValue) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Kai2Z", "Errors.Internal") - } - - return &rsaPublicKey{ - key: key{ - id: model.AggregateID, - creationDate: model.CreationDate, - changeDate: model.ChangeDate, - sequence: model.ProcessedSequence, - resourceOwner: model.ResourceOwner, - algorithm: model.Algorithm, - use: model.Usage, - }, - expiry: model.Expiry, - publicKey: publicKey, - }, nil -} diff --git a/internal/query/key_test.go b/internal/query/key_test.go deleted file mode 100644 index 7bc029fd7f..0000000000 --- a/internal/query/key_test.go +++ /dev/null @@ -1,453 +0,0 @@ -package query - -import ( - "context" - "crypto/rsa" - "database/sql" - "database/sql/driver" - "errors" - "fmt" - "io" - "math/big" - "regexp" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/eventstore" - key_repo "github.com/zitadel/zitadel/internal/repository/keypair" - "github.com/zitadel/zitadel/internal/zerrors" -) - -var ( - preparePublicKeysStmt = `SELECT projections.keys4.id,` + - ` projections.keys4.creation_date,` + - ` projections.keys4.change_date,` + - ` projections.keys4.sequence,` + - ` projections.keys4.resource_owner,` + - ` projections.keys4.algorithm,` + - ` projections.keys4.use,` + - ` projections.keys4_public.expiry,` + - ` projections.keys4_public.key,` + - ` COUNT(*) OVER ()` + - ` FROM projections.keys4` + - ` LEFT JOIN projections.keys4_public ON projections.keys4.id = projections.keys4_public.id AND projections.keys4.instance_id = projections.keys4_public.instance_id` - preparePublicKeysCols = []string{ - "id", - "creation_date", - "change_date", - "sequence", - "resource_owner", - "algorithm", - "use", - "expiry", - "key", - "count", - } - - preparePrivateKeysStmt = `SELECT projections.keys4.id,` + - ` projections.keys4.creation_date,` + - ` projections.keys4.change_date,` + - ` projections.keys4.sequence,` + - ` projections.keys4.resource_owner,` + - ` projections.keys4.algorithm,` + - ` projections.keys4.use,` + - ` projections.keys4_private.expiry,` + - ` projections.keys4_private.key,` + - ` COUNT(*) OVER ()` + - ` FROM projections.keys4` + - ` LEFT JOIN projections.keys4_private ON projections.keys4.id = projections.keys4_private.id AND projections.keys4.instance_id = projections.keys4_private.instance_id` -) - -func Test_KeyPrepares(t *testing.T) { - type want struct { - sqlExpectations sqlExpectation - err checkErr - } - tests := []struct { - name string - prepare interface{} - want want - object interface{} - }{ - { - name: "preparePublicKeysQuery no result", - prepare: preparePublicKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePublicKeysStmt), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: &PublicKeys{Keys: []PublicKey{}}, - }, - { - name: "preparePublicKeysQuery found", - prepare: preparePublicKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePublicKeysStmt), - preparePublicKeysCols, - [][]driver.Value{ - { - "key-id", - testNow, - testNow, - uint64(20211109), - "ro", - "RS256", - 0, - testNow, - []byte("-----BEGIN RSA PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsvX9P58JFxEs5C+L+H7W\nduFSWL5EPzber7C2m94klrSV6q0bAcrYQnGwFOlveThsY200hRbadKaKjHD7qIKH\nDEe0IY2PSRht33Jye52AwhkRw+M3xuQH/7R8LydnsNFk2KHpr5X2SBv42e37LjkE\nslKSaMRgJW+v0KZ30piY8QsdFRKKaVg5/Ajt1YToM1YVsdHXJ3vmXFMtypLdxwUD\ndIaLEX6pFUkU75KSuEQ/E2luT61Q3ta9kOWm9+0zvi7OMcbdekJT7mzcVnh93R1c\n13ZhQCLbh9A7si8jKFtaMWevjayrvqQABEcTN9N4Hoxcyg6l4neZtRDk75OMYcqm\nDQIDAQAB\n-----END RSA PUBLIC KEY-----\n"), - }, - }, - ), - }, - object: &PublicKeys{ - SearchResponse: SearchResponse{ - Count: 1, - }, - Keys: []PublicKey{ - &rsaPublicKey{ - key: key{ - id: "key-id", - creationDate: testNow, - changeDate: testNow, - sequence: 20211109, - resourceOwner: "ro", - algorithm: "RS256", - use: crypto.KeyUsageSigning, - }, - expiry: testNow, - publicKey: &rsa.PublicKey{ - E: 65537, - N: fromBase16("b2f5fd3f9f0917112ce42f8bf87ed676e15258be443f36deafb0b69bde2496b495eaad1b01cad84271b014e96f79386c636d348516da74a68a8c70fba882870c47b4218d8f49186ddf72727b9d80c21911c3e337c6e407ffb47c2f2767b0d164d8a1e9af95f6481bf8d9edfb2e3904b2529268c460256fafd0a677d29898f10b1d15128a695839fc08edd584e8335615b1d1d7277be65c532dca92ddc7050374868b117ea9154914ef9292b8443f13696e4fad50ded6bd90e5a6f7ed33be2ece31c6dd7a4253ee6cdc56787ddd1d5cd776614022db87d03bb22f23285b5a3167af8dacabbea40004471337d3781e8c5cca0ea5e27799b510e4ef938c61caa60d"), - }, - }, - }, - }, - }, - { - name: "preparePublicKeysQuery sql err", - prepare: preparePublicKeysQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(preparePublicKeysStmt), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: (*PublicKeys)(nil), - }, - { - name: "preparePrivateKeysQuery no result", - prepare: preparePrivateKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePrivateKeysStmt), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: &PrivateKeys{Keys: []PrivateKey{}}, - }, - { - name: "preparePrivateKeysQuery found", - prepare: preparePrivateKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePrivateKeysStmt), - preparePublicKeysCols, - [][]driver.Value{ - { - "key-id", - testNow, - testNow, - uint64(20211109), - "ro", - "RS256", - 0, - testNow, - []byte(`{"Algorithm": "enc", "Crypted": "cHJpdmF0ZUtleQ==", "CryptoType": 0, "KeyID": "id"}`), - }, - }, - ), - }, - object: &PrivateKeys{ - SearchResponse: SearchResponse{ - Count: 1, - }, - Keys: []PrivateKey{ - &privateKey{ - key: key{ - id: "key-id", - creationDate: testNow, - changeDate: testNow, - sequence: 20211109, - resourceOwner: "ro", - algorithm: "RS256", - use: crypto.KeyUsageSigning, - }, - expiry: testNow, - privateKey: &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("privateKey"), - }, - }, - }, - }, - }, - { - name: "preparePrivateKeysQuery sql err", - prepare: preparePrivateKeysQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(preparePrivateKeysStmt), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: (*PrivateKeys)(nil), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err) - }) - } -} - -func fromBase16(base16 string) *big.Int { - i, ok := new(big.Int).SetString(base16, 16) - if !ok { - panic("bad number: " + base16) - } - return i -} - -const pubKey = `-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs38btwb3c7r0tMaQpGvB -mY+mPwMU/LpfuPoC0k2t4RsKp0fv40SMl50CRrHgk395wch8PMPYbl3+8TtYAJuy -rFALIj3Ff1UcKIk0hOH5DDsfh7/q2wFuncTmS6bifYo8CfSq2vDGnM7nZnEvxY/M -fSydZdcmIqlkUpfQmtzExw9+tSe5Dxq6gn5JtlGgLgZGt69r5iMMrTEGhhVAXzNu -MZbmlCoBru+rC8ITlTX/0V1ZcsSbL8tYWhthyu9x6yjo1bH85wiVI4gs0MhU8f2a -+kjL/KGZbR14Ua2eo6tonBZLC5DHWM2TkYXgRCDPufjcgmzN0Lm91E4P8KvBcvly -6QIDAQAB ------END PUBLIC KEY----- -` - -func TestQueries_GetPublicKeyByID(t *testing.T) { - now := time.Now() - future := now.Add(time.Hour) - - tests := []struct { - name string - eventstore func(*testing.T) *eventstore.Eventstore - encryption func(*testing.T) *crypto.MockEncryptionAlgorithm - want *rsaPublicKey - wantErr error - }{ - { - name: "filter error", - eventstore: expectEventstore( - expectFilterError(io.ErrClosedPipe), - ), - wantErr: io.ErrClosedPipe, - }, - { - name: "not found error", - eventstore: expectEventstore( - expectFilter(), - ), - wantErr: zerrors.ThrowNotFound(nil, "QUERY-Ahf7x", "Errors.Key.NotFound"), - }, - { - name: "decrypt error", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher(key_repo.NewAddedEvent(context.Background(), - &eventstore.Aggregate{ - ID: "keyID", - Type: key_repo.AggregateType, - ResourceOwner: "instanceID", - InstanceID: "instanceID", - Version: key_repo.AggregateVersion, - }, - crypto.KeyUsageSigning, "alg", - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("private"), - }, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("public"), - }, - future, - future, - )), - ), - ), - encryption: func(t *testing.T) *crypto.MockEncryptionAlgorithm { - encryption := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) - expect := encryption.EXPECT() - expect.Algorithm().Return("alg") - expect.DecryptionKeyIDs().Return([]string{}) - return encryption - }, - wantErr: zerrors.ThrowInternal(nil, "QUERY-Ie4oh", "Errors.Internal"), - }, - { - name: "parse error", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher(key_repo.NewAddedEvent(context.Background(), - &eventstore.Aggregate{ - ID: "keyID", - Type: key_repo.AggregateType, - ResourceOwner: "instanceID", - InstanceID: "instanceID", - Version: key_repo.AggregateVersion, - }, - crypto.KeyUsageSigning, "alg", - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("private"), - }, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("public"), - }, - future, - future, - )), - ), - ), - encryption: func(t *testing.T) *crypto.MockEncryptionAlgorithm { - encryption := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) - expect := encryption.EXPECT() - expect.Algorithm().Return("alg") - expect.DecryptionKeyIDs().Return([]string{"keyID"}) - expect.Decrypt([]byte("public"), "keyID").Return([]byte("foo"), nil) - return encryption - }, - wantErr: zerrors.ThrowInternal(nil, "QUERY-Kai2Z", "Errors.Internal"), - }, - { - name: "success", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher(key_repo.NewAddedEvent(context.Background(), - &eventstore.Aggregate{ - ID: "keyID", - Type: key_repo.AggregateType, - ResourceOwner: "instanceID", - InstanceID: "instanceID", - Version: key_repo.AggregateVersion, - }, - crypto.KeyUsageSigning, "alg", - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("private"), - }, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("public"), - }, - future, - future, - )), - ), - ), - encryption: func(t *testing.T) *crypto.MockEncryptionAlgorithm { - encryption := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) - expect := encryption.EXPECT() - expect.Algorithm().Return("alg") - expect.DecryptionKeyIDs().Return([]string{"keyID"}) - expect.Decrypt([]byte("public"), "keyID").Return([]byte(pubKey), nil) - return encryption - }, - want: &rsaPublicKey{ - key: key{ - id: "keyID", - resourceOwner: "instanceID", - algorithm: "alg", - use: crypto.KeyUsageSigning, - }, - expiry: future, - publicKey: func() *rsa.PublicKey { - publicKey, err := crypto.BytesToPublicKey([]byte(pubKey)) - if err != nil { - panic(err) - } - return publicKey - }(), - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - q := &Queries{ - eventstore: tt.eventstore(t), - } - if tt.encryption != nil { - q.keyEncryptionAlgorithm = tt.encryption(t) - } - ctx := authz.NewMockContext("instanceID", "orgID", "loginClient") - key, err := q.GetPublicKeyByID(ctx, "keyID") - if tt.wantErr != nil { - require.ErrorIs(t, err, tt.wantErr) - return - } - require.NoError(t, err) - require.NotNil(t, key) - - got := key.(*rsaPublicKey) - assert.WithinDuration(t, tt.want.expiry, got.expiry, time.Second) - tt.want.expiry = time.Time{} - got.expiry = time.Time{} - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/internal/query/member.go b/internal/query/member.go index 584ae15d1c..8a4ca3302d 100644 --- a/internal/query/member.go +++ b/internal/query/member.go @@ -35,8 +35,17 @@ func NewMemberLastNameSearchQuery(method TextComparison, value string) (SearchQu } func NewMemberUserIDSearchQuery(value string) (SearchQuery, error) { - return NewTextQuery(membershipUserID, value, TextEquals) + return NewTextQuery(MembershipUserID, value, TextEquals) } + +func NewMemberInUserIDsSearchQuery(ids []string) (SearchQuery, error) { + list := make([]interface{}, len(ids)) + for i, value := range ids { + list[i] = value + } + return NewListQuery(MembershipUserID, list, ListIn) +} + func NewMemberResourceOwnerSearchQuery(value string) (SearchQuery, error) { return NewTextQuery(membershipResourceOwner, value, TextEquals) } diff --git a/internal/query/project_grant_member.go b/internal/query/project_grant_member.go index a9cc49c498..2f469dce80 100644 --- a/internal/query/project_grant_member.go +++ b/internal/query/project_grant_member.go @@ -128,7 +128,7 @@ func prepareProjectGrantMembersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Memb LeftJoin(join(MachineUserIDCol, ProjectGrantMemberUserID)). LeftJoin(join(UserIDCol, ProjectGrantMemberUserID)). LeftJoin(join(LoginNameUserIDCol, ProjectGrantMemberUserID)). - LeftJoin(join(ProjectGrantColumnGrantID, ProjectGrantMemberGrantID)). + LeftJoin(join(ProjectGrantColumnGrantID, ProjectGrantMemberGrantID) + " AND " + ProjectGrantMemberProjectID.identifier() + " = " + ProjectGrantColumnProjectID.identifier()). Where( sq.Eq{LoginNameIsPrimaryCol.identifier(): true}, ).PlaceholderFormat(sq.Dollar), diff --git a/internal/query/project_grant_member_test.go b/internal/query/project_grant_member_test.go index 23d1258b7c..be0f5f572e 100644 --- a/internal/query/project_grant_member_test.go +++ b/internal/query/project_grant_member_test.go @@ -46,6 +46,7 @@ var ( "LEFT JOIN projections.project_grants4 " + "ON members.grant_id = projections.project_grants4.grant_id " + "AND members.instance_id = projections.project_grants4.instance_id " + + "AND members.project_id = projections.project_grants4.project_id " + "WHERE projections.login_names3.is_primary = $1") projectGrantMembersColumns = []string{ "creation_date", diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 34100a0d66..32ec2cf111 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -64,14 +64,6 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceLoginDefaultOrgEventType, Reduce: reduceInstanceSetFeature[bool], }, - { - Event: feature_v2.InstanceTriggerIntrospectionProjectionsEventType, - Reduce: reduceInstanceSetFeature[bool], - }, - { - Event: feature_v2.InstanceLegacyIntrospectionEventType, - Reduce: reduceInstanceSetFeature[bool], - }, { Event: feature_v2.InstanceUserSchemaEventType, Reduce: reduceInstanceSetFeature[bool], @@ -84,10 +76,6 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceImprovedPerformanceEventType, Reduce: reduceInstanceSetFeature[[]feature.ImprovedPerformanceType], }, - { - Event: feature_v2.InstanceWebKeyEventType, - Reduce: reduceInstanceSetFeature[bool], - }, { Event: feature_v2.InstanceDebugOIDCParentErrorEventType, Reduce: reduceInstanceSetFeature[bool], diff --git a/internal/query/projection/instance_features_test.go b/internal/query/projection/instance_features_test.go index 4a4a46727f..703a0ce00a 100644 --- a/internal/query/projection/instance_features_test.go +++ b/internal/query/projection/instance_features_test.go @@ -26,7 +26,7 @@ func TestInstanceFeaturesProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - feature_v2.InstanceLegacyIntrospectionEventType, + feature_v2.SystemUserSchemaEventType, feature_v2.AggregateType, []byte(`{"value": true}`), ), eventstore.GenericEventMapper[feature_v2.SetEvent[bool]]), @@ -41,7 +41,7 @@ func TestInstanceFeaturesProjection_reduces(t *testing.T) { expectedStmt: "INSERT INTO projections.instance_features2 (instance_id, key, creation_date, change_date, sequence, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (instance_id, key) DO UPDATE SET (creation_date, change_date, sequence, value) = (projections.instance_features2.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.value)", expectedArgs: []interface{}{ "agg-id", - "legacy_introspection", + "user_schema", anyArg{}, anyArg{}, uint64(15), diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index de54054e78..32f49108e6 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -56,14 +56,6 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemLoginDefaultOrgEventType, Reduce: reduceSystemSetFeature[bool], }, - { - Event: feature_v2.SystemTriggerIntrospectionProjectionsEventType, - Reduce: reduceSystemSetFeature[bool], - }, - { - Event: feature_v2.SystemLegacyIntrospectionEventType, - Reduce: reduceSystemSetFeature[bool], - }, { Event: feature_v2.SystemUserSchemaEventType, Reduce: reduceSystemSetFeature[bool], diff --git a/internal/query/projection/system_features_test.go b/internal/query/projection/system_features_test.go index 9bc19573cc..b64db7fb0a 100644 --- a/internal/query/projection/system_features_test.go +++ b/internal/query/projection/system_features_test.go @@ -24,7 +24,7 @@ func TestSystemFeaturesProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - feature_v2.SystemLegacyIntrospectionEventType, + feature_v2.SystemUserSchemaEventType, feature_v2.AggregateType, []byte(`{"value": true}`), ), eventstore.GenericEventMapper[feature_v2.SetEvent[bool]]), @@ -38,7 +38,7 @@ func TestSystemFeaturesProjection_reduces(t *testing.T) { { expectedStmt: "INSERT INTO projections.system_features (key, creation_date, change_date, sequence, value) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key) DO UPDATE SET (creation_date, change_date, sequence, value) = (projections.system_features.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.value)", expectedArgs: []interface{}{ - "legacy_introspection", + "user_schema", anyArg{}, anyArg{}, uint64(15), diff --git a/internal/query/system_features.go b/internal/query/system_features.go index dcbbb7d6fe..34410cb9b8 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -20,17 +20,15 @@ func (f *FeatureSource[T]) set(level feature.Level, value any) { type SystemFeatures struct { Details *domain.ObjectDetails - LoginDefaultOrg FeatureSource[bool] - TriggerIntrospectionProjections FeatureSource[bool] - LegacyIntrospection FeatureSource[bool] - UserSchema FeatureSource[bool] - TokenExchange FeatureSource[bool] - ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] - OIDCSingleV1SessionTermination FeatureSource[bool] - DisableUserTokenEvent FeatureSource[bool] - EnableBackChannelLogout FeatureSource[bool] - LoginV2 FeatureSource[*feature.LoginV2] - PermissionCheckV2 FeatureSource[bool] + LoginDefaultOrg FeatureSource[bool] + UserSchema FeatureSource[bool] + TokenExchange FeatureSource[bool] + ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] + OIDCSingleV1SessionTermination FeatureSource[bool] + DisableUserTokenEvent FeatureSource[bool] + EnableBackChannelLogout FeatureSource[bool] + LoginV2 FeatureSource[*feature.LoginV2] + PermissionCheckV2 FeatureSource[bool] } func (q *Queries) GetSystemFeatures(ctx context.Context) (_ *SystemFeatures, err error) { diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index 69e1f35968..67045f314d 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -56,8 +56,6 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { EventTypes( feature_v2.SystemResetEventType, feature_v2.SystemLoginDefaultOrgEventType, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, - feature_v2.SystemLegacyIntrospectionEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, feature_v2.SystemImprovedPerformanceEventType, @@ -81,15 +79,10 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S return err } switch key { - case feature.KeyUnspecified, - feature.KeyActionsDeprecated: + case feature.KeyUnspecified: return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) - case feature.KeyTriggerIntrospectionProjections: - features.TriggerIntrospectionProjections.set(level, event.Value) - case feature.KeyLegacyIntrospection: - features.LegacyIntrospection.set(level, event.Value) case feature.KeyUserSchema: features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: diff --git a/internal/query/system_features_test.go b/internal/query/system_features_test.go index 5a58ac23d7..7aa12a6a8f 100644 --- a/internal/query/system_features_test.go +++ b/internal/query/system_features_test.go @@ -49,14 +49,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -71,14 +63,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelSystem, Value: false, @@ -93,14 +77,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -109,10 +85,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), ), ), want: &SystemFeatures{ @@ -123,14 +95,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, @@ -145,14 +109,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -161,10 +117,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), ), ), want: &SystemFeatures{ @@ -175,14 +127,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, diff --git a/internal/query/user_grant.go b/internal/query/user_grant.go index ebd4ab7c0c..212972ea8c 100644 --- a/internal/query/user_grant.go +++ b/internal/query/user_grant.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -44,8 +45,9 @@ type UserGrant struct { OrgName string `json:"org_name,omitempty"` OrgPrimaryDomain string `json:"org_primary_domain,omitempty"` - ProjectID string `json:"project_id,omitempty"` - ProjectName string `json:"project_name,omitempty"` + ProjectResourceOwner string `json:"project_resource_owner,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` GrantedOrgID string `json:"granted_org_id,omitempty"` GrantedOrgName string `json:"granted_org_name,omitempty"` @@ -57,6 +59,27 @@ type UserGrants struct { UserGrants []*UserGrant } +func userGrantsCheckPermission(ctx context.Context, grants *UserGrants, permissionCheck domain.PermissionCheck) { + grants.UserGrants = slices.DeleteFunc(grants.UserGrants, + func(grant *UserGrant) bool { + return userGrantCheckPermission(ctx, grant.ResourceOwner, grant.ProjectID, grant.GrantID, grant.UserID, permissionCheck) != nil + }, + ) +} + +func userGrantCheckPermission(ctx context.Context, resourceOwner, projectID, grantID, userID string, permissionCheck domain.PermissionCheck) error { + // you should always be able to read your own permissions + if authz.GetCtxData(ctx).UserID == userID { + return nil + } + // check permission on the project grant + if grantID != "" { + return permissionCheck(ctx, domain.PermissionUserGrantRead, resourceOwner, grantID) + } + // check on project + return permissionCheck(ctx, domain.PermissionUserGrantRead, resourceOwner, projectID) +} + type UserGrantsQueries struct { SearchRequest Queries []SearchQuery @@ -70,6 +93,21 @@ func (q *UserGrantsQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { return query } +func userGrantPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *UserGrantsQueries) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + UserGrantResourceOwner, + domain.PermissionUserGrantRead, + SingleOrgPermissionOption(queries.Queries), + WithProjectsPermissionOption(UserGrantProjectID), + OwnedRowsPermissionOption(UserGrantUserID), + ) + return query.JoinClause(join, args...) +} + func NewUserGrantUserIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantUserID, id, TextEquals) } @@ -78,14 +116,6 @@ func NewUserGrantProjectIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantProjectID, id, TextEquals) } -func NewUserGrantProjectIDsSearchQuery(ids []string) (SearchQuery, error) { - list := make([]interface{}, len(ids)) - for i, value := range ids { - list[i] = value - } - return NewListQuery(UserGrantProjectID, list, ListIn) -} - func NewUserGrantProjectOwnerSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(ProjectColumnResourceOwner, id, TextEquals) } @@ -94,6 +124,10 @@ func NewUserGrantResourceOwnerSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantResourceOwner, id, TextEquals) } +func NewUserGrantUserResourceOwnerSearchQuery(id string) (SearchQuery, error) { + return NewTextQuery(UserResourceOwnerCol, id, TextEquals) +} + func NewUserGrantGrantIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantGrantID, id, TextEquals) } @@ -102,6 +136,14 @@ func NewUserGrantIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantID, id, TextEquals) } +func NewUserGrantInIDsSearchQuery(ids []string) (SearchQuery, error) { + list := make([]interface{}, len(ids)) + for i, value := range ids { + list[i] = value + } + return NewListQuery(UserGrantID, list, ListIn) +} + func NewUserGrantUserTypeQuery(typ domain.UserType) (SearchQuery, error) { return NewNumberQuery(UserTypeCol, typ, NumberEquals) } @@ -262,7 +304,19 @@ func (q *Queries) UserGrant(ctx context.Context, shouldTriggerBulk bool, queries return grant, err } -func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, shouldTriggerBulk bool) (grants *UserGrants, err error) { +func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, shouldTriggerBulk bool, permissionCheck domain.PermissionCheck) (*UserGrants, error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + grants, err := q.userGrants(ctx, queries, shouldTriggerBulk, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + userGrantsCheckPermission(ctx, grants, permissionCheck) + } + return grants, nil +} + +func (q *Queries) userGrants(ctx context.Context, queries *UserGrantsQueries, shouldTriggerBulk bool, permissionCheckV2 bool) (grants *UserGrants, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -274,6 +328,7 @@ func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, sh } query, scan := prepareUserGrantsQuery() + query = userGrantPermissionCheckV2(ctx, query, permissionCheckV2, queries) eq := sq.Eq{UserGrantInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { @@ -324,6 +379,7 @@ func prepareUserGrantQuery() (sq.SelectBuilder, func(*sql.Row) (*UserGrant, erro UserGrantProjectID.identifier(), ProjectColumnName.identifier(), + ProjectColumnResourceOwner.identifier(), GrantedOrgColumnId.identifier(), GrantedOrgColumnName.identifier(), @@ -334,7 +390,8 @@ func prepareUserGrantQuery() (sq.SelectBuilder, func(*sql.Row) (*UserGrant, erro LeftJoin(join(HumanUserIDCol, UserGrantUserID)). LeftJoin(join(OrgColumnID, UserGrantResourceOwner)). LeftJoin(join(ProjectColumnID, UserGrantProjectID)). - LeftJoin(join(GrantedOrgColumnId, UserResourceOwnerCol)). + LeftJoin(join(ProjectGrantColumnGrantID, UserGrantGrantID) + " AND " + ProjectGrantColumnProjectID.identifier() + " = " + UserGrantProjectID.identifier()). + LeftJoin(join(GrantedOrgColumnId, ProjectGrantColumnGrantedOrgID)). LeftJoin(join(LoginNameUserIDCol, UserGrantUserID)). Where( sq.Eq{LoginNameIsPrimaryCol.identifier(): true}, @@ -356,7 +413,8 @@ func prepareUserGrantQuery() (sq.SelectBuilder, func(*sql.Row) (*UserGrant, erro orgName sql.NullString orgDomain sql.NullString - projectName sql.NullString + projectName sql.NullString + projectResourceOwner sql.NullString grantedOrgID sql.NullString grantedOrgName sql.NullString @@ -389,6 +447,7 @@ func prepareUserGrantQuery() (sq.SelectBuilder, func(*sql.Row) (*UserGrant, erro &g.ProjectID, &projectName, + &projectResourceOwner, &grantedOrgID, &grantedOrgName, @@ -413,6 +472,7 @@ func prepareUserGrantQuery() (sq.SelectBuilder, func(*sql.Row) (*UserGrant, erro g.OrgName = orgName.String g.OrgPrimaryDomain = orgDomain.String g.ProjectName = projectName.String + g.ProjectResourceOwner = projectResourceOwner.String g.GrantedOrgID = grantedOrgID.String g.GrantedOrgName = grantedOrgName.String g.GrantedOrgDomain = grantedOrgDomain.String @@ -447,6 +507,7 @@ func prepareUserGrantsQuery() (sq.SelectBuilder, func(*sql.Rows) (*UserGrants, e UserGrantProjectID.identifier(), ProjectColumnName.identifier(), + ProjectColumnResourceOwner.identifier(), GrantedOrgColumnId.identifier(), GrantedOrgColumnName.identifier(), @@ -459,7 +520,8 @@ func prepareUserGrantsQuery() (sq.SelectBuilder, func(*sql.Rows) (*UserGrants, e LeftJoin(join(HumanUserIDCol, UserGrantUserID)). LeftJoin(join(OrgColumnID, UserGrantResourceOwner)). LeftJoin(join(ProjectColumnID, UserGrantProjectID)). - LeftJoin(join(GrantedOrgColumnId, UserResourceOwnerCol)). + LeftJoin(join(ProjectGrantColumnGrantID, UserGrantGrantID) + " AND " + ProjectGrantColumnProjectID.identifier() + " = " + UserGrantProjectID.identifier()). + LeftJoin(join(GrantedOrgColumnId, ProjectGrantColumnGrantedOrgID)). LeftJoin(join(LoginNameUserIDCol, UserGrantUserID)). Where( sq.Eq{LoginNameIsPrimaryCol.identifier(): true}, @@ -488,7 +550,8 @@ func prepareUserGrantsQuery() (sq.SelectBuilder, func(*sql.Rows) (*UserGrants, e grantedOrgName sql.NullString grantedOrgDomain sql.NullString - projectName sql.NullString + projectName sql.NullString + projectResourceOwner sql.NullString ) err := rows.Scan( @@ -517,6 +580,7 @@ func prepareUserGrantsQuery() (sq.SelectBuilder, func(*sql.Rows) (*UserGrants, e &g.ProjectID, &projectName, + &projectResourceOwner, &grantedOrgID, &grantedOrgName, @@ -540,6 +604,7 @@ func prepareUserGrantsQuery() (sq.SelectBuilder, func(*sql.Rows) (*UserGrants, e g.OrgName = orgName.String g.OrgPrimaryDomain = orgDomain.String g.ProjectName = projectName.String + g.ProjectResourceOwner = projectResourceOwner.String g.GrantedOrgID = grantedOrgID.String g.GrantedOrgName = grantedOrgName.String g.GrantedOrgDomain = grantedOrgDomain.String diff --git a/internal/query/user_grant_test.go b/internal/query/user_grant_test.go index 6a640c2ef2..dde04f2c88 100644 --- a/internal/query/user_grant_test.go +++ b/internal/query/user_grant_test.go @@ -37,6 +37,7 @@ var ( ", projections.orgs1.primary_domain" + ", projections.user_grants5.project_id" + ", projections.projects4.name" + + ", projections.projects4.resource_owner" + ", granted_orgs.id" + ", granted_orgs.name" + ", granted_orgs.primary_domain" + @@ -45,7 +46,8 @@ var ( " LEFT JOIN projections.users14_humans ON projections.user_grants5.user_id = projections.users14_humans.user_id AND projections.user_grants5.instance_id = projections.users14_humans.instance_id" + " LEFT JOIN projections.orgs1 ON projections.user_grants5.resource_owner = projections.orgs1.id AND projections.user_grants5.instance_id = projections.orgs1.instance_id" + " LEFT JOIN projections.projects4 ON projections.user_grants5.project_id = projections.projects4.id AND projections.user_grants5.instance_id = projections.projects4.instance_id" + - " LEFT JOIN projections.orgs1 AS granted_orgs ON projections.users14.resource_owner = granted_orgs.id AND projections.users14.instance_id = granted_orgs.instance_id" + + " LEFT JOIN projections.project_grants4 ON projections.user_grants5.grant_id = projections.project_grants4.grant_id AND projections.user_grants5.instance_id = projections.project_grants4.instance_id AND projections.project_grants4.project_id = projections.user_grants5.project_id" + + " LEFT JOIN projections.orgs1 AS granted_orgs ON projections.project_grants4.granted_org_id = granted_orgs.id AND projections.project_grants4.instance_id = granted_orgs.instance_id" + " LEFT JOIN projections.login_names3 ON projections.user_grants5.user_id = projections.login_names3.user_id AND projections.user_grants5.instance_id = projections.login_names3.instance_id" + " WHERE projections.login_names3.is_primary = $1") userGrantCols = []string{ @@ -71,6 +73,7 @@ var ( "primary_domain", "project_id", "name", // project name + "resource_owner", // project_grant resource owner "id", // granted org id "name", // granted org name "primary_domain", // granted org domain @@ -98,6 +101,7 @@ var ( ", projections.orgs1.primary_domain" + ", projections.user_grants5.project_id" + ", projections.projects4.name" + + ", projections.projects4.resource_owner" + ", granted_orgs.id" + ", granted_orgs.name" + ", granted_orgs.primary_domain" + @@ -107,7 +111,8 @@ var ( " LEFT JOIN projections.users14_humans ON projections.user_grants5.user_id = projections.users14_humans.user_id AND projections.user_grants5.instance_id = projections.users14_humans.instance_id" + " LEFT JOIN projections.orgs1 ON projections.user_grants5.resource_owner = projections.orgs1.id AND projections.user_grants5.instance_id = projections.orgs1.instance_id" + " LEFT JOIN projections.projects4 ON projections.user_grants5.project_id = projections.projects4.id AND projections.user_grants5.instance_id = projections.projects4.instance_id" + - " LEFT JOIN projections.orgs1 AS granted_orgs ON projections.users14.resource_owner = granted_orgs.id AND projections.users14.instance_id = granted_orgs.instance_id" + + " LEFT JOIN projections.project_grants4 ON projections.user_grants5.grant_id = projections.project_grants4.grant_id AND projections.user_grants5.instance_id = projections.project_grants4.instance_id AND projections.project_grants4.project_id = projections.user_grants5.project_id" + + " LEFT JOIN projections.orgs1 AS granted_orgs ON projections.project_grants4.granted_org_id = granted_orgs.id AND projections.project_grants4.instance_id = granted_orgs.instance_id" + " LEFT JOIN projections.login_names3 ON projections.user_grants5.user_id = projections.login_names3.user_id AND projections.user_grants5.instance_id = projections.login_names3.instance_id" + " WHERE projections.login_names3.is_primary = $1") userGrantsCols = append( @@ -175,6 +180,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -182,31 +188,32 @@ func Test_UserGrantPrepares(t *testing.T) { ), }, object: &UserGrant{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, { @@ -239,6 +246,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -246,31 +254,32 @@ func Test_UserGrantPrepares(t *testing.T) { ), }, object: &UserGrant{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeMachine, - UserResourceOwner: "resource-owner", - FirstName: "", - LastName: "", - Email: "", - DisplayName: "", - AvatarURL: "", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeMachine, + UserResourceOwner: "resource-owner", + FirstName: "", + LastName: "", + Email: "", + DisplayName: "", + AvatarURL: "", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, { @@ -303,6 +312,7 @@ func Test_UserGrantPrepares(t *testing.T) { nil, "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -310,31 +320,32 @@ func Test_UserGrantPrepares(t *testing.T) { ), }, object: &UserGrant{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "", - OrgPrimaryDomain: "", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "", + OrgPrimaryDomain: "", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, { @@ -367,6 +378,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", nil, + nil, "granted-org-id", "granted-org-name", "granted-org-domain", @@ -374,31 +386,32 @@ func Test_UserGrantPrepares(t *testing.T) { ), }, object: &UserGrant{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "", + ProjectResourceOwner: "", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, { @@ -431,6 +444,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -438,31 +452,32 @@ func Test_UserGrantPrepares(t *testing.T) { ), }, object: &UserGrant{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, { @@ -525,6 +540,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -538,31 +554,32 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, @@ -598,6 +615,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -611,31 +629,32 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeMachine, - UserResourceOwner: "resource-owner", - FirstName: "", - LastName: "", - Email: "", - DisplayName: "", - AvatarURL: "", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeMachine, + UserResourceOwner: "resource-owner", + FirstName: "", + LastName: "", + Email: "", + DisplayName: "", + AvatarURL: "", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, @@ -671,6 +690,7 @@ func Test_UserGrantPrepares(t *testing.T) { nil, "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -684,31 +704,32 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeMachine, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "", - OrgPrimaryDomain: "", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeMachine, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "", + OrgPrimaryDomain: "", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, @@ -744,6 +765,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", nil, + nil, "granted-org-id", "granted-org-name", "granted-org-domain", @@ -757,31 +779,32 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "", + ProjectResourceOwner: "", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, @@ -817,6 +840,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -830,31 +854,32 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, @@ -890,6 +915,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -917,6 +943,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -930,58 +957,60 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, diff --git a/internal/query/user_membership.go b/internal/query/user_membership.go index cb7588624f..069210a830 100644 --- a/internal/query/user_membership.go +++ b/internal/query/user_membership.go @@ -63,7 +63,15 @@ type MembershipSearchQuery struct { } func NewMembershipUserIDQuery(userID string) (SearchQuery, error) { - return NewTextQuery(membershipUserID.setTable(membershipAlias), userID, TextEquals) + return NewTextQuery(MembershipUserID.setTable(membershipAlias), userID, TextEquals) +} + +func NewMembershipCreationDateQuery(timestamp time.Time, comparison TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(MembershipCreationDate.setTable(membershipAlias), timestamp, comparison) +} + +func NewMembershipChangeDateQuery(timestamp time.Time, comparison TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(MembershipChangeDate.setTable(membershipAlias), timestamp, comparison) } func NewMembershipOrgIDQuery(value string) (SearchQuery, error) { @@ -137,7 +145,7 @@ func (q *Queries) Memberships(ctx context.Context, queries *MembershipSearchQuer wg.Wait() } - query, queryArgs, scan := prepareMembershipsQuery(queries) + query, queryArgs, scan := prepareMembershipsQuery(ctx, queries, false) eq := sq.Eq{membershipInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { @@ -166,7 +174,7 @@ var ( name: "members", instanceIDCol: projection.MemberInstanceID, } - membershipUserID = Column{ + MembershipUserID = Column{ name: projection.MemberUserIDCol, table: membershipAlias, } @@ -174,11 +182,11 @@ var ( name: projection.MemberRolesCol, table: membershipAlias, } - membershipCreationDate = Column{ + MembershipCreationDate = Column{ name: projection.MemberCreationDate, table: membershipAlias, } - membershipChangeDate = Column{ + MembershipChangeDate = Column{ name: projection.MemberChangeDate, table: membershipAlias, } @@ -216,11 +224,11 @@ var ( } ) -func getMembershipFromQuery(queries *MembershipSearchQuery) (string, []interface{}) { - orgMembers, orgMembersArgs := prepareOrgMember(queries) - iamMembers, iamMembersArgs := prepareIAMMember(queries) - projectMembers, projectMembersArgs := prepareProjectMember(queries) - projectGrantMembers, projectGrantMembersArgs := prepareProjectGrantMember(queries) +func getMembershipFromQuery(ctx context.Context, queries *MembershipSearchQuery, permissionV2 bool) (string, []interface{}) { + orgMembers, orgMembersArgs := prepareOrgMember(ctx, queries, permissionV2) + iamMembers, iamMembersArgs := prepareIAMMember(ctx, queries, permissionV2) + projectMembers, projectMembersArgs := prepareProjectMember(ctx, queries, permissionV2) + projectGrantMembers, projectGrantMembersArgs := prepareProjectGrantMember(ctx, queries, permissionV2) args := make([]interface{}, 0) args = append(append(append(append(args, orgMembersArgs...), iamMembersArgs...), projectMembersArgs...), projectGrantMembersArgs...) @@ -236,13 +244,13 @@ func getMembershipFromQuery(queries *MembershipSearchQuery) (string, []interface args } -func prepareMembershipsQuery(queries *MembershipSearchQuery) (sq.SelectBuilder, []interface{}, func(*sql.Rows) (*Memberships, error)) { - query, args := getMembershipFromQuery(queries) +func prepareMembershipsQuery(ctx context.Context, queries *MembershipSearchQuery, permissionV2 bool) (sq.SelectBuilder, []interface{}, func(*sql.Rows) (*Memberships, error)) { + query, args := getMembershipFromQuery(ctx, queries, permissionV2) return sq.Select( - membershipUserID.identifier(), + MembershipUserID.identifier(), membershipRoles.identifier(), - membershipCreationDate.identifier(), - membershipChangeDate.identifier(), + MembershipCreationDate.identifier(), + MembershipChangeDate.identifier(), membershipSequence.identifier(), membershipResourceOwner.identifier(), membershipOrgID.identifier(), @@ -257,7 +265,7 @@ func prepareMembershipsQuery(queries *MembershipSearchQuery) (sq.SelectBuilder, ).From(query). LeftJoin(join(ProjectColumnID, membershipProjectID)). LeftJoin(join(OrgColumnID, membershipOrgID)). - LeftJoin(join(ProjectGrantColumnGrantID, membershipGrantID)). + LeftJoin(join(ProjectGrantColumnGrantID, membershipGrantID) + " AND " + membershipProjectID.identifier() + " = " + ProjectGrantColumnProjectID.identifier()). LeftJoin(join(InstanceColumnID, membershipInstanceID)). PlaceholderFormat(sq.Dollar), args, @@ -340,7 +348,7 @@ func prepareMembershipsQuery(queries *MembershipSearchQuery) (sq.SelectBuilder, } } -func prepareOrgMember(query *MembershipSearchQuery) (string, []interface{}) { +func prepareOrgMember(ctx context.Context, query *MembershipSearchQuery, permissionV2 bool) (string, []interface{}) { builder := sq.Select( OrgMemberUserID.identifier(), OrgMemberRoles.identifier(), @@ -354,6 +362,7 @@ func prepareOrgMember(query *MembershipSearchQuery) (string, []interface{}) { "NULL::TEXT AS "+membershipProjectID.name, "NULL::TEXT AS "+membershipGrantID.name, ).From(orgMemberTable.identifier()) + builder = administratorOrgPermissionCheckV2(ctx, builder, permissionV2) for _, q := range query.Queries { if q.Col().table.name == membershipAlias.name || q.Col().table.name == orgMemberTable.name { @@ -363,7 +372,7 @@ func prepareOrgMember(query *MembershipSearchQuery) (string, []interface{}) { return builder.MustSql() } -func prepareIAMMember(query *MembershipSearchQuery) (string, []interface{}) { +func prepareIAMMember(ctx context.Context, query *MembershipSearchQuery, permissionV2 bool) (string, []interface{}) { builder := sq.Select( InstanceMemberUserID.identifier(), InstanceMemberRoles.identifier(), @@ -377,6 +386,7 @@ func prepareIAMMember(query *MembershipSearchQuery) (string, []interface{}) { "NULL::TEXT AS "+membershipProjectID.name, "NULL::TEXT AS "+membershipGrantID.name, ).From(instanceMemberTable.identifier()) + builder = administratorInstancePermissionCheckV2(ctx, builder, permissionV2) for _, q := range query.Queries { if q.Col().table.name == membershipAlias.name || q.Col().table.name == instanceMemberTable.name { @@ -386,7 +396,7 @@ func prepareIAMMember(query *MembershipSearchQuery) (string, []interface{}) { return builder.MustSql() } -func prepareProjectMember(query *MembershipSearchQuery) (string, []interface{}) { +func prepareProjectMember(ctx context.Context, query *MembershipSearchQuery, permissionV2 bool) (string, []interface{}) { builder := sq.Select( ProjectMemberUserID.identifier(), ProjectMemberRoles.identifier(), @@ -400,6 +410,7 @@ func prepareProjectMember(query *MembershipSearchQuery) (string, []interface{}) ProjectMemberProjectID.identifier(), "NULL::TEXT AS "+membershipGrantID.name, ).From(projectMemberTable.identifier()) + builder = administratorProjectPermissionCheckV2(ctx, builder, permissionV2) for _, q := range query.Queries { if q.Col().table.name == membershipAlias.name || q.Col().table.name == projectMemberTable.name { @@ -410,7 +421,7 @@ func prepareProjectMember(query *MembershipSearchQuery) (string, []interface{}) return builder.MustSql() } -func prepareProjectGrantMember(query *MembershipSearchQuery) (string, []interface{}) { +func prepareProjectGrantMember(ctx context.Context, query *MembershipSearchQuery, permissionV2 bool) (string, []interface{}) { builder := sq.Select( ProjectGrantMemberUserID.identifier(), ProjectGrantMemberRoles.identifier(), @@ -424,6 +435,7 @@ func prepareProjectGrantMember(query *MembershipSearchQuery) (string, []interfac ProjectGrantMemberProjectID.identifier(), ProjectGrantMemberGrantID.identifier(), ).From(projectGrantMemberTable.identifier()) + builder = administratorProjectGrantPermissionCheckV2(ctx, builder, permissionV2) for _, q := range query.Queries { if q.Col().table.name == membershipAlias.name || q.Col().table.name == projectMemberTable.name || q.Col().table.name == projectGrantMemberTable.name { diff --git a/internal/query/user_membership_test.go b/internal/query/user_membership_test.go index b0170182d1..ce48770fd8 100644 --- a/internal/query/user_membership_test.go +++ b/internal/query/user_membership_test.go @@ -1,6 +1,7 @@ package query import ( + "context" "database/sql" "database/sql/driver" "errors" @@ -85,7 +86,7 @@ var ( ") AS members" + " LEFT JOIN projections.projects4 ON members.project_id = projections.projects4.id AND members.instance_id = projections.projects4.instance_id" + " LEFT JOIN projections.orgs1 ON members.org_id = projections.orgs1.id AND members.instance_id = projections.orgs1.instance_id" + - " LEFT JOIN projections.project_grants4 ON members.grant_id = projections.project_grants4.grant_id AND members.instance_id = projections.project_grants4.instance_id" + + " LEFT JOIN projections.project_grants4 ON members.grant_id = projections.project_grants4.grant_id AND members.instance_id = projections.project_grants4.instance_id AND members.project_id = projections.project_grants4.project_id" + " LEFT JOIN projections.instances ON members.instance_id = projections.instances.id") membershipCols = []string{ "user_id", @@ -461,7 +462,7 @@ func Test_MembershipPrepares(t *testing.T) { func prepareMembershipWrapper() func() (sq.SelectBuilder, func(*sql.Rows) (*Memberships, error)) { return func() (sq.SelectBuilder, func(*sql.Rows) (*Memberships, error)) { - builder, _, fun := prepareMembershipsQuery(&MembershipSearchQuery{}) + builder, _, fun := prepareMembershipsQuery(context.Background(), &MembershipSearchQuery{}, false) return builder, fun } } diff --git a/internal/query/user_metadata.go b/internal/query/user_metadata.go index 534c707593..385c176e0a 100644 --- a/internal/query/user_metadata.go +++ b/internal/query/user_metadata.go @@ -4,12 +4,14 @@ import ( "context" "database/sql" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -36,6 +38,28 @@ type UserMetadataSearchQueries struct { Queries []SearchQuery } +func userMetadataCheckPermission(ctx context.Context, userMetadataList *UserMetadataList, permissionCheck domain.PermissionCheck) { + userMetadataList.Metadata = slices.DeleteFunc(userMetadataList.Metadata, + func(userMetadata *UserMetadata) bool { + return userCheckPermission(ctx, userMetadata.ResourceOwner, userMetadata.UserID, permissionCheck) != nil + }, + ) +} + +func userMetadataPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *UserMetadataSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + UserMetadataResourceOwnerCol, + domain.PermissionUserRead, + SingleOrgPermissionOption(queries.Queries), + OwnedRowsPermissionOption(UserMetadataUserIDCol), + ) + return query.JoinClause(join, args...) +} + var ( userMetadataTable = table{ name: projection.UserMetadataProjectionTable, @@ -139,7 +163,19 @@ func (q *Queries) SearchUserMetadataForUsers(ctx context.Context, shouldTriggerB return metadata, err } -func (q *Queries) SearchUserMetadata(ctx context.Context, shouldTriggerBulk bool, userID string, queries *UserMetadataSearchQueries, withOwnerRemoved bool) (metadata *UserMetadataList, err error) { +func (q *Queries) SearchUserMetadata(ctx context.Context, shouldTriggerBulk bool, userID string, queries *UserMetadataSearchQueries, permissionCheck domain.PermissionCheck) (metadata *UserMetadataList, err error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + users, err := q.searchUserMetadata(ctx, shouldTriggerBulk, userID, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + userMetadataCheckPermission(ctx, users, permissionCheck) + } + return users, nil +} + +func (q *Queries) searchUserMetadata(ctx context.Context, shouldTriggerBulk bool, userID string, queries *UserMetadataSearchQueries, permissionCheckV2 bool) (metadata *UserMetadataList, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -151,6 +187,7 @@ func (q *Queries) SearchUserMetadata(ctx context.Context, shouldTriggerBulk bool } query, scan := prepareUserMetadataListQuery() + query = userMetadataPermissionCheckV2(ctx, query, permissionCheckV2, queries) eq := sq.Eq{ UserMetadataUserIDCol.identifier(): userID, UserMetadataInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), diff --git a/internal/query/userinfo.go b/internal/query/userinfo.go index 0e749f09b3..aa2920dfba 100644 --- a/internal/query/userinfo.go +++ b/internal/query/userinfo.go @@ -31,12 +31,6 @@ var oidcUserInfoTriggerHandlers = sync.OnceValue(func() []*handler.Handler { } }) -// TriggerOIDCUserInfoProjections triggers all projections -// relevant to userinfo queries concurrently. -func TriggerOIDCUserInfoProjections(ctx context.Context) { - triggerBatch(ctx, oidcUserInfoTriggerHandlers()...) -} - var ( //go:embed userinfo_by_id.sql oidcUserInfoQueryTmpl string diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 22df8c2b5c..44e291bf4d 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -9,6 +9,7 @@ import ( "github.com/riverqueue/river/riverdriver/riverpgxv5" "github.com/riverqueue/river/rivertype" "github.com/riverqueue/rivercontrib/otelriver" + "github.com/robfig/cron/v3" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" @@ -75,6 +76,26 @@ func (q *Queue) AddWorkers(w ...Worker) { } } +func (q *Queue) AddPeriodicJob(schedule cron.Schedule, jobArgs river.JobArgs, opts ...InsertOpt) (handle rivertype.PeriodicJobHandle) { + if q == nil { + logging.Info("skip adding periodic job because queue is not set") + return + } + options := new(river.InsertOpts) + for _, opt := range opts { + opt(options) + } + return q.client.PeriodicJobs().Add( + river.NewPeriodicJob( + schedule, + func() (river.JobArgs, *river.InsertOpts) { + return jobArgs, options + }, + nil, + ), + ) +} + type InsertOpt func(*river.InsertOpts) func WithMaxAttempts(maxAttempts uint8) InsertOpt { diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index 00618f56c2..293c1ee3cd 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -8,8 +8,6 @@ import ( func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, SystemLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, SystemTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, SystemLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) @@ -21,12 +19,9 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceWebKeyEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceDebugOIDCParentErrorEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceOIDCSingleV1SessionTerminationEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index d5e8941df2..2859b65ebf 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -11,34 +11,29 @@ import ( ) var ( - SystemResetEventType = resetEventTypeFromFeature(feature.LevelSystem) - SystemLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginDefaultOrg) - SystemTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTriggerIntrospectionProjections) - SystemLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLegacyIntrospection) - SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) - SystemTokenExchangeEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTokenExchange) - SystemImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyImprovedPerformance) - SystemOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyOIDCSingleV1SessionTermination) - SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) - SystemEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelSystem, feature.KeyEnableBackChannelLogout) - SystemLoginVersion = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginV2) - SystemPermissionCheckV2 = setEventTypeFromFeature(feature.LevelSystem, feature.KeyPermissionCheckV2) + SystemResetEventType = resetEventTypeFromFeature(feature.LevelSystem) + SystemLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginDefaultOrg) + SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) + SystemTokenExchangeEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTokenExchange) + SystemImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyImprovedPerformance) + SystemOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyOIDCSingleV1SessionTermination) + SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) + SystemEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelSystem, feature.KeyEnableBackChannelLogout) + SystemLoginVersion = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginV2) + SystemPermissionCheckV2 = setEventTypeFromFeature(feature.LevelSystem, feature.KeyPermissionCheckV2) - InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) - InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) - InstanceTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTriggerIntrospectionProjections) - InstanceLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLegacyIntrospection) - InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) - InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) - InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) - InstanceWebKeyEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyWebKey) - InstanceDebugOIDCParentErrorEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDebugOIDCParentError) - InstanceOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyOIDCSingleV1SessionTermination) - InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) - InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout) - InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2) - InstancePermissionCheckV2 = setEventTypeFromFeature(feature.LevelInstance, feature.KeyPermissionCheckV2) - InstanceConsoleUseV2UserApi = setEventTypeFromFeature(feature.LevelInstance, feature.KeyConsoleUseV2UserApi) + InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) + InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) + InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) + InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) + InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) + InstanceDebugOIDCParentErrorEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDebugOIDCParentError) + InstanceOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyOIDCSingleV1SessionTermination) + InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) + InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout) + InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2) + InstancePermissionCheckV2 = setEventTypeFromFeature(feature.LevelInstance, feature.KeyPermissionCheckV2) + InstanceConsoleUseV2UserApi = setEventTypeFromFeature(feature.LevelInstance, feature.KeyConsoleUseV2UserApi) ) const ( diff --git a/internal/repository/org/aggregate.go b/internal/repository/org/aggregate.go index 58121afbcb..ff9be33085 100644 --- a/internal/repository/org/aggregate.go +++ b/internal/repository/org/aggregate.go @@ -1,6 +1,8 @@ package org import ( + "context" + "github.com/zitadel/zitadel/internal/eventstore" ) @@ -27,3 +29,7 @@ func NewAggregate(id string) *Aggregate { }, } } + +func AggregateFromWriteModel(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return eventstore.AggregateFromWriteModelCtx(ctx, wm, AggregateType, AggregateVersion) +} diff --git a/internal/serviceping/client.go b/internal/serviceping/client.go new file mode 100644 index 0000000000..87711aada6 --- /dev/null +++ b/internal/serviceping/client.go @@ -0,0 +1,153 @@ +package serviceping + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "net/http" + + "google.golang.org/grpc" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + analytics "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta" +) + +const ( + pathBaseInformation = "/instances" + pathResourceCounts = "/resource_counts" +) + +type Client struct { + httpClient *http.Client + endpoint string +} + +func (c Client) ReportBaseInformation(ctx context.Context, in *analytics.ReportBaseInformationRequest, opts ...grpc.CallOption) (*analytics.ReportBaseInformationResponse, error) { + reportResponse := new(analytics.ReportBaseInformationResponse) + err := c.callTelemetryService(ctx, pathBaseInformation, in, reportResponse) + if err != nil { + return nil, err + } + return reportResponse, nil +} + +func (c Client) ReportResourceCounts(ctx context.Context, in *analytics.ReportResourceCountsRequest, opts ...grpc.CallOption) (*analytics.ReportResourceCountsResponse, error) { + reportResponse := new(analytics.ReportResourceCountsResponse) + err := c.callTelemetryService(ctx, pathResourceCounts, in, reportResponse) + if err != nil { + return nil, err + } + return reportResponse, nil +} + +func (c Client) callTelemetryService(ctx context.Context, path string, in proto.Message, out proto.Message) error { + requestBody, err := protojson.Marshal(in) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+path, bytes.NewReader(requestBody)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return &TelemetryError{ + StatusCode: resp.StatusCode, + Body: body, + } + } + + return protojson.UnmarshalOptions{ + AllowPartial: true, + DiscardUnknown: true, + }.Unmarshal(body, out) +} + +func NewClient(config *Config) Client { + return Client{ + httpClient: http.DefaultClient, + endpoint: config.Endpoint, + } +} + +func GenerateSystemID() (string, error) { + randBytes := make([]byte, 64) + if _, err := rand.Read(randBytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(randBytes), nil +} + +func instanceInformationToPb(instances *query.Instances) []*analytics.InstanceInformation { + instanceInformation := make([]*analytics.InstanceInformation, len(instances.Instances)) + for i, instance := range instances.Instances { + domains := instanceDomainToPb(instance) + instanceInformation[i] = &analytics.InstanceInformation{ + Id: instance.ID, + Domains: domains, + CreatedAt: timestamppb.New(instance.CreationDate), + } + } + return instanceInformation +} + +func instanceDomainToPb(instance *query.Instance) []string { + domains := make([]string, len(instance.Domains)) + for i, domain := range instance.Domains { + domains[i] = domain.Domain + } + return domains +} + +func resourceCountsToPb(counts []query.ResourceCount) []*analytics.ResourceCount { + resourceCounts := make([]*analytics.ResourceCount, len(counts)) + for i, count := range counts { + resourceCounts[i] = &analytics.ResourceCount{ + InstanceId: count.InstanceID, + ParentType: countParentTypeToPb(count.ParentType), + ParentId: count.ParentID, + ResourceName: count.Resource, + TableName: count.TableName, + UpdatedAt: timestamppb.New(count.UpdatedAt), + Amount: uint32(count.Amount), + } + } + return resourceCounts +} + +func countParentTypeToPb(parentType domain.CountParentType) analytics.CountParentType { + switch parentType { + case domain.CountParentTypeInstance: + return analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE + case domain.CountParentTypeOrganization: + return analytics.CountParentType_COUNT_PARENT_TYPE_ORGANIZATION + default: + return analytics.CountParentType_COUNT_PARENT_TYPE_UNSPECIFIED + } +} + +type TelemetryError struct { + StatusCode int + Body []byte +} + +func (e *TelemetryError) Error() string { + return fmt.Sprintf("telemetry error %d: %s", e.StatusCode, e.Body) +} diff --git a/internal/serviceping/config.go b/internal/serviceping/config.go new file mode 100644 index 0000000000..13f2311324 --- /dev/null +++ b/internal/serviceping/config.go @@ -0,0 +1,18 @@ +package serviceping + +type Config struct { + Enabled bool + Endpoint string + Interval string + MaxAttempts uint8 + Telemetry TelemetryConfig +} + +type TelemetryConfig struct { + ResourceCount ResourceCount +} + +type ResourceCount struct { + Enabled bool + BulkSize int +} diff --git a/internal/serviceping/mock/mock_gen.go b/internal/serviceping/mock/mock_gen.go new file mode 100644 index 0000000000..6b4d2defbe --- /dev/null +++ b/internal/serviceping/mock/mock_gen.go @@ -0,0 +1,5 @@ +package mock + +//go:generate mockgen -package mock -destination queue.mock.go github.com/zitadel/zitadel/internal/serviceping Queue +//go:generate mockgen -package mock -destination queries.mock.go github.com/zitadel/zitadel/internal/serviceping Queries +//go:generate mockgen -package mock -destination telemetry.mock.go github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta TelemetryServiceClient diff --git a/internal/serviceping/mock/queries.mock.go b/internal/serviceping/mock/queries.mock.go new file mode 100644 index 0000000000..593c4d5ff7 --- /dev/null +++ b/internal/serviceping/mock/queries.mock.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/internal/serviceping (interfaces: Queries) +// +// Generated by this command: +// +// mockgen -package mock -destination queries.mock.go github.com/zitadel/zitadel/internal/serviceping Queries +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + query "github.com/zitadel/zitadel/internal/query" + gomock "go.uber.org/mock/gomock" +) + +// MockQueries is a mock of Queries interface. +type MockQueries struct { + ctrl *gomock.Controller + recorder *MockQueriesMockRecorder + isgomock struct{} +} + +// MockQueriesMockRecorder is the mock recorder for MockQueries. +type MockQueriesMockRecorder struct { + mock *MockQueries +} + +// NewMockQueries creates a new mock instance. +func NewMockQueries(ctrl *gomock.Controller) *MockQueries { + mock := &MockQueries{ctrl: ctrl} + mock.recorder = &MockQueriesMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockQueries) EXPECT() *MockQueriesMockRecorder { + return m.recorder +} + +// ListResourceCounts mocks base method. +func (m *MockQueries) ListResourceCounts(ctx context.Context, lastID, size int) ([]query.ResourceCount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListResourceCounts", ctx, lastID, size) + ret0, _ := ret[0].([]query.ResourceCount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListResourceCounts indicates an expected call of ListResourceCounts. +func (mr *MockQueriesMockRecorder) ListResourceCounts(ctx, lastID, size any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListResourceCounts", reflect.TypeOf((*MockQueries)(nil).ListResourceCounts), ctx, lastID, size) +} + +// SearchInstances mocks base method. +func (m *MockQueries) SearchInstances(ctx context.Context, queries *query.InstanceSearchQueries) (*query.Instances, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchInstances", ctx, queries) + ret0, _ := ret[0].(*query.Instances) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchInstances indicates an expected call of SearchInstances. +func (mr *MockQueriesMockRecorder) SearchInstances(ctx, queries any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstances", reflect.TypeOf((*MockQueries)(nil).SearchInstances), ctx, queries) +} diff --git a/internal/serviceping/mock/queue.mock.go b/internal/serviceping/mock/queue.mock.go new file mode 100644 index 0000000000..e984352a8c --- /dev/null +++ b/internal/serviceping/mock/queue.mock.go @@ -0,0 +1,62 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/internal/serviceping (interfaces: Queue) +// +// Generated by this command: +// +// mockgen -package mock -destination queue.mock.go github.com/zitadel/zitadel/internal/serviceping Queue +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + river "github.com/riverqueue/river" + queue "github.com/zitadel/zitadel/internal/queue" + gomock "go.uber.org/mock/gomock" +) + +// MockQueue is a mock of Queue interface. +type MockQueue struct { + ctrl *gomock.Controller + recorder *MockQueueMockRecorder + isgomock struct{} +} + +// MockQueueMockRecorder is the mock recorder for MockQueue. +type MockQueueMockRecorder struct { + mock *MockQueue +} + +// NewMockQueue creates a new mock instance. +func NewMockQueue(ctrl *gomock.Controller) *MockQueue { + mock := &MockQueue{ctrl: ctrl} + mock.recorder = &MockQueueMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockQueue) EXPECT() *MockQueueMockRecorder { + return m.recorder +} + +// Insert mocks base method. +func (m *MockQueue) Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error { + m.ctrl.T.Helper() + varargs := []any{ctx, args} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Insert", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Insert indicates an expected call of Insert. +func (mr *MockQueueMockRecorder) Insert(ctx, args any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, args}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockQueue)(nil).Insert), varargs...) +} diff --git a/internal/serviceping/mock/telemetry.mock.go b/internal/serviceping/mock/telemetry.mock.go new file mode 100644 index 0000000000..536bf34671 --- /dev/null +++ b/internal/serviceping/mock/telemetry.mock.go @@ -0,0 +1,83 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta (interfaces: TelemetryServiceClient) +// +// Generated by this command: +// +// mockgen -package mock -destination telemetry.mock.go github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta TelemetryServiceClient +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + analytics "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta" + gomock "go.uber.org/mock/gomock" + grpc "google.golang.org/grpc" +) + +// MockTelemetryServiceClient is a mock of TelemetryServiceClient interface. +type MockTelemetryServiceClient struct { + ctrl *gomock.Controller + recorder *MockTelemetryServiceClientMockRecorder + isgomock struct{} +} + +// MockTelemetryServiceClientMockRecorder is the mock recorder for MockTelemetryServiceClient. +type MockTelemetryServiceClientMockRecorder struct { + mock *MockTelemetryServiceClient +} + +// NewMockTelemetryServiceClient creates a new mock instance. +func NewMockTelemetryServiceClient(ctrl *gomock.Controller) *MockTelemetryServiceClient { + mock := &MockTelemetryServiceClient{ctrl: ctrl} + mock.recorder = &MockTelemetryServiceClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTelemetryServiceClient) EXPECT() *MockTelemetryServiceClientMockRecorder { + return m.recorder +} + +// ReportBaseInformation mocks base method. +func (m *MockTelemetryServiceClient) ReportBaseInformation(ctx context.Context, in *analytics.ReportBaseInformationRequest, opts ...grpc.CallOption) (*analytics.ReportBaseInformationResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, in} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ReportBaseInformation", varargs...) + ret0, _ := ret[0].(*analytics.ReportBaseInformationResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReportBaseInformation indicates an expected call of ReportBaseInformation. +func (mr *MockTelemetryServiceClientMockRecorder) ReportBaseInformation(ctx, in any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, in}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReportBaseInformation", reflect.TypeOf((*MockTelemetryServiceClient)(nil).ReportBaseInformation), varargs...) +} + +// ReportResourceCounts mocks base method. +func (m *MockTelemetryServiceClient) ReportResourceCounts(ctx context.Context, in *analytics.ReportResourceCountsRequest, opts ...grpc.CallOption) (*analytics.ReportResourceCountsResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, in} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ReportResourceCounts", varargs...) + ret0, _ := ret[0].(*analytics.ReportResourceCountsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReportResourceCounts indicates an expected call of ReportResourceCounts. +func (mr *MockTelemetryServiceClientMockRecorder) ReportResourceCounts(ctx, in any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, in}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReportResourceCounts", reflect.TypeOf((*MockTelemetryServiceClient)(nil).ReportResourceCounts), varargs...) +} diff --git a/internal/serviceping/report.go b/internal/serviceping/report.go new file mode 100644 index 0000000000..d31f6a8f74 --- /dev/null +++ b/internal/serviceping/report.go @@ -0,0 +1,17 @@ +package serviceping + +type ReportType uint + +const ( + ReportTypeBaseInformation ReportType = iota + ReportTypeResourceCounts +) + +type ServicePingReport struct { + ReportID string + ReportType ReportType +} + +func (r *ServicePingReport) Kind() string { + return "service_ping_report" +} diff --git a/internal/serviceping/worker.go b/internal/serviceping/worker.go new file mode 100644 index 0000000000..b95dd77fa1 --- /dev/null +++ b/internal/serviceping/worker.go @@ -0,0 +1,293 @@ +package serviceping + +import ( + "context" + "errors" + "fmt" + "math/rand" + "net/http" + "time" + + "github.com/muhlemmer/gu" + "github.com/riverqueue/river" + "github.com/robfig/cron/v3" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/cmd/build" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/queue" + "github.com/zitadel/zitadel/internal/v2/system" + "github.com/zitadel/zitadel/internal/zerrors" + analytics "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta" +) + +const ( + QueueName = "service_ping_report" + minInterval = 30 * time.Minute +) + +var ( + ErrInvalidReportType = errors.New("invalid report type") + + _ river.Worker[*ServicePingReport] = (*Worker)(nil) +) + +type Worker struct { + river.WorkerDefaults[*ServicePingReport] + + reportClient analytics.TelemetryServiceClient + db Queries + queue Queue + + config *Config + systemID string + version string +} + +type Queries interface { + SearchInstances(ctx context.Context, queries *query.InstanceSearchQueries) (*query.Instances, error) + ListResourceCounts(ctx context.Context, lastID int, size int) ([]query.ResourceCount, error) +} + +type Queue interface { + Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error +} + +// Register implements the [queue.Worker] interface. +func (w *Worker) Register(workers *river.Workers, queues map[string]river.QueueConfig) { + river.AddWorker[*ServicePingReport](workers, w) + queues[QueueName] = river.QueueConfig{ + MaxWorkers: 1, // for now, we only use a single worker to prevent too much side effects on other queues + } +} + +// Work implements the [river.Worker] interface. +func (w *Worker) Work(ctx context.Context, job *river.Job[*ServicePingReport]) (err error) { + defer func() { + err = w.handleClientError(err) + }() + switch job.Args.ReportType { + case ReportTypeBaseInformation: + reportID, err := w.reportBaseInformation(ctx) + if err != nil { + return err + } + return w.createReportJobs(ctx, reportID) + case ReportTypeResourceCounts: + return w.reportResourceCounts(ctx, job.Args.ReportID) + default: + logging.WithFields("reportType", job.Args.ReportType, "reportID", job.Args.ReportID). + Error("unknown job type") + return river.JobCancel(ErrInvalidReportType) + } +} + +func (w *Worker) reportBaseInformation(ctx context.Context) (string, error) { + instances, err := w.db.SearchInstances(ctx, &query.InstanceSearchQueries{}) + if err != nil { + return "", err + } + instanceInformation := instanceInformationToPb(instances) + resp, err := w.reportClient.ReportBaseInformation(ctx, &analytics.ReportBaseInformationRequest{ + SystemId: w.systemID, + Version: w.version, + Instances: instanceInformation, + }) + if err != nil { + return "", err + } + return resp.GetReportId(), nil +} + +func (w *Worker) reportResourceCounts(ctx context.Context, reportID string) error { + lastID := 0 + // iterate over the resource counts until there are no more counts to report + // or the context gets cancelled + for { + select { + case <-ctx.Done(): + return nil + default: + counts, err := w.db.ListResourceCounts(ctx, lastID, w.config.Telemetry.ResourceCount.BulkSize) + if err != nil { + return err + } + // if there are no counts, we can stop the loop + if len(counts) == 0 { + return nil + } + request := &analytics.ReportResourceCountsRequest{ + SystemId: w.systemID, + ResourceCounts: resourceCountsToPb(counts), + } + if reportID != "" { + request.ReportId = gu.Ptr(reportID) + } + resp, err := w.reportClient.ReportResourceCounts(ctx, request) + if err != nil { + return err + } + // in case the resource counts returned by the database are less than the bulk size, + // we can assume that we have reached the end of the resource counts and can stop the loop + if len(counts) < w.config.Telemetry.ResourceCount.BulkSize { + return nil + } + // update the lastID for the next iteration + lastID = counts[len(counts)-1].ID + // In case we get a report ID back from the server (it could be the first call of the report), + // we update it to use it for the next batch. + if resp.GetReportId() != "" && resp.GetReportId() != reportID { + reportID = resp.GetReportId() + } + } + } +} + +func (w *Worker) handleClientError(err error) error { + telemetryError := new(TelemetryError) + if !errors.As(err, &telemetryError) { + // If the error is not a TelemetryError, we can assume that it is a transient error + // and can be retried by the queue. + return err + } + switch telemetryError.StatusCode { + case http.StatusBadRequest, + http.StatusNotFound, + http.StatusNotImplemented, + http.StatusConflict, + http.StatusPreconditionFailed: + // In case of these errors, we can assume that a retry does not make sense, + // so we can cancel the job. + return river.JobCancel(err) + default: + // As of now we assume that all other errors are transient and can be retried. + // So we just return the error, which will be handled by the queue as a failed attempt. + return err + } +} + +func (w *Worker) createReportJobs(ctx context.Context, reportID string) error { + errs := make([]error, 0) + if w.config.Telemetry.ResourceCount.Enabled { + err := w.addReportJob(ctx, reportID, ReportTypeResourceCounts) + if err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func (w *Worker) addReportJob(ctx context.Context, reportID string, reportType ReportType) error { + job := &ServicePingReport{ + ReportID: reportID, + ReportType: reportType, + } + return w.queue.Insert(ctx, job, + queue.WithQueueName(QueueName), + queue.WithMaxAttempts(w.config.MaxAttempts), + ) +} + +type systemIDReducer struct { + id string +} + +func (s *systemIDReducer) Reduce() error { + return nil +} + +func (s *systemIDReducer) AppendEvents(events ...eventstore.Event) { + for _, event := range events { + if idEvent, ok := event.(*system.IDGeneratedEvent); ok { + s.id = idEvent.ID + } + } +} + +func (s *systemIDReducer) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(system.AggregateType). + EventTypes(system.IDGeneratedType). + Builder() +} + +func Register( + ctx context.Context, + q *queue.Queue, + queries *query.Queries, + eventstoreClient *eventstore.Eventstore, + config *Config, +) error { + if !config.Enabled { + return nil + } + systemID := new(systemIDReducer) + err := eventstoreClient.FilterToQueryReducer(ctx, systemID) + if err != nil { + return err + } + q.AddWorkers(&Worker{ + reportClient: NewClient(config), + db: queries, + queue: q, + config: config, + systemID: systemID.id, + version: build.Version(), + }) + return nil +} + +func Start(config *Config, q *queue.Queue) error { + if !config.Enabled { + return nil + } + schedule, err := parseAndValidateSchedule(config.Interval) + if err != nil { + return err + } + q.AddPeriodicJob( + schedule, + &ServicePingReport{}, + queue.WithQueueName(QueueName), + queue.WithMaxAttempts(config.MaxAttempts), + ) + return nil +} + +func parseAndValidateSchedule(interval string) (cron.Schedule, error) { + if interval == "@daily" { + interval = randomizeDaily() + } + schedule, err := cron.ParseStandard(interval) + if err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "SERV-NJqiof", "invalid interval") + } + var intervalDuration time.Duration + switch s := schedule.(type) { + case *cron.SpecSchedule: + // For cron.SpecSchedule, we need to calculate the interval duration + // by getting the next time and subtracting it from the time after that. + // This is because the schedule could be a specific time, that is less than 30 minutes away, + // but still run only once a day and therefore is valid. + next := s.Next(time.Now()) + nextAfter := s.Next(next) + intervalDuration = nextAfter.Sub(next) + case cron.ConstantDelaySchedule: + intervalDuration = s.Delay + } + if intervalDuration < minInterval { + return nil, zerrors.ThrowInvalidArgumentf(nil, "SERV-FJ12", "interval must be at least %s", minInterval) + } + logging.WithFields("interval", interval).Info("scheduling service ping") + return schedule, nil +} + +// randomizeDaily generates a random time for the daily cron job +// to prevent all systems from sending the report at the same time. +func randomizeDaily() string { + minute := rand.Intn(60) + hour := rand.Intn(24) + return fmt.Sprintf("%d %d * * *", minute, hour) +} diff --git a/internal/serviceping/worker_test.go b/internal/serviceping/worker_test.go new file mode 100644 index 0000000000..f5bd38d3eb --- /dev/null +++ b/internal/serviceping/worker_test.go @@ -0,0 +1,1126 @@ +package serviceping + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/riverqueue/river" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/queue" + "github.com/zitadel/zitadel/internal/serviceping/mock" + "github.com/zitadel/zitadel/internal/zerrors" + analytics "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta" +) + +var ( + testNow = time.Now() + errInsert = fmt.Errorf("insert error") +) + +func TestWorker_reportBaseInformation(t *testing.T) { + type fields struct { + reportClient func(*testing.T) analytics.TelemetryServiceClient + db func(*testing.T) Queries + systemID string + version string + } + type args struct { + ctx context.Context + } + type want struct { + reportID string + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "database error, error", + fields: fields{ + db: func(t *testing.T) Queries { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + nil, zerrors.ThrowInternal(nil, "id", "db error"), + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + return mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + }, + }, + want: want{ + reportID: "", + err: zerrors.ThrowInternal(nil, "id", "db error"), + }, + }, + { + name: "telemetry client error, error", + fields: fields{ + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain", "domain2"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + nil, status.Error(codes.Internal, "error"), + ) + return client + }, + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + { + Domain: "domain2", + }, + }, + }, + }, + }, + nil, + ) + return queries + }, + systemID: "system-id", + version: "version", + }, + want: want{ + reportID: "", + err: status.Error(codes.Internal, "error"), + }, + }, + { + name: "report ok, reportID returned", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + { + Domain: "domain2", + }, + }, + }, + }, + }, + nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain", "domain2"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + &analytics.ReportBaseInformationResponse{ReportId: "report-id"}, nil, + ) + return client + }, + systemID: "system-id", + version: "version", + }, + want: want{ + reportID: "report-id", + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &Worker{ + reportClient: tt.fields.reportClient(t), + db: tt.fields.db(t), + systemID: tt.fields.systemID, + version: tt.fields.version, + } + got, err := w.reportBaseInformation(tt.args.ctx) + assert.Equal(t, tt.want.reportID, got) + assert.ErrorIs(t, err, tt.want.err) + }) + } +} + +func TestWorker_reportResourceCounts(t *testing.T) { + type fields struct { + reportClient func(*testing.T) analytics.TelemetryServiceClient + db func(*testing.T) Queries + config *Config + systemID string + } + type args struct { + ctx context.Context + reportID string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "database error, error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 1).Return( + nil, zerrors.ThrowInternal(nil, "id", "db error"), + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + return mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 1, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + reportID: "", + }, + wantErr: zerrors.ThrowInternal(nil, "id", "db error"), + }, + { + name: "no resource counts, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 1).Return( + []query.ResourceCount{}, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + return mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 1, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + reportID: "", + }, + wantErr: nil, + }, + { + name: "telemetry client error, error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: nil, + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + }, + }).Return( + nil, status.Error(codes.Internal, "error"), + ) + return client + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + reportID: "", + }, + wantErr: status.Error(codes.Internal, "error"), + }, + { + name: "report ok, no additional counts, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: nil, + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + }, + }).Return( + &analytics.ReportResourceCountsResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + reportID: "", + }, + wantErr: nil, + }, + { + name: "report ok, additional counts, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + { + ID: 2, + InstanceID: "instance-id2", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id2", + Resource: "resource", + UpdatedAt: testNow, + Amount: 5, + }, + }, nil, + ) + queries.EXPECT().ListResourceCounts(gomock.Any(), 2, 2).Return( + []query.ResourceCount{ + { + ID: 3, + InstanceID: "instance-id3", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id3", + Resource: "resource", + UpdatedAt: testNow, + Amount: 20, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: nil, + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + { + InstanceId: "instance-id2", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id2", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 5, + }, + }, + }).Return( + &analytics.ReportResourceCountsResponse{ + ReportId: "report-id", + }, nil, + ) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: gu.Ptr("report-id"), + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id3", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id3", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 20, + }, + }, + }).Return( + &analytics.ReportResourceCountsResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + reportID: "", + }, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &Worker{ + reportClient: tt.fields.reportClient(t), + db: tt.fields.db(t), + config: tt.fields.config, + systemID: tt.fields.systemID, + } + err := w.reportResourceCounts(tt.args.ctx, tt.args.reportID) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestWorker_Work(t *testing.T) { + type fields struct { + WorkerDefaults river.WorkerDefaults[*ServicePingReport] + reportClient func(*testing.T) analytics.TelemetryServiceClient + db func(*testing.T) Queries + queue func(*testing.T) Queue + config *Config + systemID string + version string + } + type args struct { + ctx context.Context + job *river.Job[*ServicePingReport] + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "unknown report type, cancel job", + fields: fields{ + db: func(t *testing.T) Queries { + return mock.NewMockQueries(gomock.NewController(t)) + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + return mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: 100000, + }, + }, + }, + wantErr: river.JobCancel(ErrInvalidReportType), + }, + { + name: "report base information, database error, retry job", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + nil, zerrors.ThrowInternal(nil, "id", "db error"), + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + return mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeBaseInformation, + }, + }, + }, + wantErr: zerrors.ThrowInternal(nil, "id", "db error"), + }, + { + name: "report base information, config error, cancel job", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + }, + }, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + nil, &TelemetryError{StatusCode: http.StatusNotFound, Body: []byte("endpoint not found")}, + ) + return client + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + systemID: "system-id", + version: "version", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeBaseInformation, + }, + }, + }, + wantErr: river.JobCancel(&TelemetryError{StatusCode: http.StatusNotFound, Body: []byte("endpoint not found")}), + }, + { + name: "report base information, no reports enabled, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + }, + }, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + &analytics.ReportBaseInformationResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + Enabled: false, + }, + }, + }, + systemID: "system-id", + version: "version", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeBaseInformation, + }, + }, + }, + }, + { + name: "report base information, job creation error, cancel job", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + }, + }, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + &analytics.ReportBaseInformationResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + queue: func(t *testing.T) Queue { + q := mock.NewMockQueue(gomock.NewController(t)) + q.EXPECT().Insert(gomock.Any(), + &ServicePingReport{ + ReportID: "report-id", + ReportType: ReportTypeResourceCounts, + }, + gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithQueueName(QueueName))), + gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithMaxAttempts(5)))). // TODO: better solution + Return(errInsert) + return q + }, + config: &Config{ + MaxAttempts: 5, + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + Enabled: true, + }, + }, + }, + systemID: "system-id", + version: "version", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeBaseInformation, + }, + }, + }, + wantErr: errInsert, + }, + { + name: "report base information, success, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + }, + }, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + &analytics.ReportBaseInformationResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + queue: func(t *testing.T) Queue { + q := mock.NewMockQueue(gomock.NewController(t)) + q.EXPECT().Insert(gomock.Any(), + &ServicePingReport{ + ReportID: "report-id", + ReportType: ReportTypeResourceCounts, + }, + gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithQueueName(QueueName))), + gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithMaxAttempts(5)))). + Return(nil) + return q + }, + config: &Config{ + MaxAttempts: 5, + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + Enabled: true, + }, + }, + }, + systemID: "system-id", + version: "version", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeBaseInformation, + }, + }, + }, + }, + { + name: "report resource counts, service unavailable, retry job", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: gu.Ptr("report-id"), + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + }, + }).Return( + nil, status.Error(codes.Unavailable, "service unavailable"), + ) + return client + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeResourceCounts, + ReportID: "report-id", + }, + }, + }, + wantErr: status.Error(codes.Unavailable, "service unavailable"), + }, + { + name: "report resource counts, precondition error, cancel job", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: gu.Ptr("report-id"), + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + }, + }).Return( + nil, &TelemetryError{StatusCode: http.StatusPreconditionFailed, Body: []byte("report too old")}, + ) + return client + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportID: "report-id", + ReportType: ReportTypeResourceCounts, + }, + }, + }, + wantErr: river.JobCancel(&TelemetryError{StatusCode: http.StatusPreconditionFailed, Body: []byte("report too old")}), + }, + { + name: "report resource counts, success, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: gu.Ptr("report-id"), + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + }, + }).Return( + &analytics.ReportResourceCountsResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportID: "report-id", + ReportType: ReportTypeResourceCounts, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &Worker{ + WorkerDefaults: river.WorkerDefaults[*ServicePingReport]{}, + reportClient: tt.fields.reportClient(t), + db: tt.fields.db(t), + queue: tt.fields.queue(t), + config: tt.fields.config, + systemID: tt.fields.systemID, + version: tt.fields.version, + } + err := w.Work(tt.args.ctx, tt.args.job) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func Test_parseAndValidateSchedule(t *testing.T) { + type args struct { + interval string + } + tests := []struct { + name string + args args + wantNextStart time.Time + wantNextEnd time.Time + wantErr error + }{ + { + name: "@daily, returns randomized daily schedule", + args: args{ + interval: "@daily", + }, + wantNextStart: time.Now(), + wantNextEnd: time.Now().Add(24 * time.Hour), + }, + { + name: "invalid cron expression, returns error", + args: args{ + interval: "invalid cron", + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "SERV-NJqiof", "invalid interval"), + }, + { + name: "valid cron expression, returns schedule", + args: args{ + interval: "0 0 * * *", + }, + wantNextStart: nextMidnight(), + wantNextEnd: nextMidnight(), + }, + { + name: "valid cron expression (extended syntax), returns schedule", + args: args{ + interval: "@midnight", + }, + wantNextStart: nextMidnight(), + wantNextEnd: nextMidnight(), + }, + { + name: "less than minInterval, returns error", + args: args{ + interval: "0/15 * * * *", + }, + wantErr: zerrors.ThrowInvalidArgumentf(nil, "SERV-FJ12", "interval must be at least %s", minInterval), + }, + { + name: "less than minInterval (extended syntax), returns error", + args: args{ + interval: "@every 15m", + }, + wantErr: zerrors.ThrowInvalidArgumentf(nil, "SERV-FJ12", "interval must be at least %s", minInterval), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAndValidateSchedule(tt.args.interval) + assert.ErrorIs(t, err, tt.wantErr) + if tt.wantErr == nil { + now := time.Now() + assert.WithinRange(t, got.Next(now), tt.wantNextStart, tt.wantNextEnd) + } + }) + } +} + +func nextMidnight() time.Time { + year, month, day := time.Now().Date() + return time.Date(year, month, day+1, 0, 0, 0, 0, time.Local) +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index d58b2eb64a..5c7742b5f7 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -448,8 +448,6 @@ Errors: Invalid: Потребителското разрешение е невалидно NotChanged: Потребителското разрешение не е променено IDMissing: ID липсва - NotActive: Потребителското разрешение не е активно - NotInactive: Предоставянето на потребител не е деактивирано NoPermissionForProject: Потребителят няма разрешения за този проект RoleKeyNotFound: Ролята не е намерена Member: diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index d248ce4ca7..26e7c5b6f4 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Uživatelský grant je neplatný NotChanged: Uživatelský grant nebyl změněn IDMissing: Chybí Id - NotActive: Uživatelský grant není aktivní - NotInactive: Uživatelský grant není deaktivován NoPermissionForProject: Uživatel nemá na tomto projektu žádná oprávnění RoleKeyNotFound: Role nenalezena Member: diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 96edf57456..b7e36febf4 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Benutzer Berechtigung ist ungültig NotChanged: Benutzer Berechtigung wurde nicht verändert IDMissing: ID fehlt - NotActive: Benutzer Berechtigung ist nicht aktiv - NotInactive: Benutzer Berechtigung ist nicht deaktiviert NoPermissionForProject: Benutzer hat keine Rechte auf diesem Projekt RoleKeyNotFound: Rolle konnte nicht gefunden werden Member: diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 0f512defe4..7385a4a025 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -437,8 +437,6 @@ Errors: Invalid: User grant is invalid NotChanged: User grant has not been changed IDMissing: Id missing - NotActive: User grant is not active - NotInactive: User grant is not deactivated NoPermissionForProject: User has no permissions on this project RoleKeyNotFound: Role not found Member: diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 8c901f8ebe..a41c7818c6 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -436,8 +436,6 @@ Errors: Invalid: La concesión de usuario no es válida NotChanged: La concesión de usuario no ha cambiado IDMissing: Falta Id - NotActive: La concesión de usuario no está activa - NotInactive: La concesión de usuario no está inactiva NoPermissionForProject: El usuario no tiene permisos en este proyecto RoleKeyNotFound: Rol no encontrado Member: diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 2a2a51d7c4..f414419216 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -436,8 +436,6 @@ Errors: Invalid: La subvention d'utilisateur n'est pas valide NotChanged: L'autorisation de l'utilisateur n'a pas été modifiée. IDMissing: Id manquant - NotActive: La subvention de l'utilisateur n'est pas active - NotInactive: La subvention à l'utilisateur n'est pas désactivée NoPermissionForProject: L'utilisateur n'a aucune autorisation pour ce projet RoleKeyNotFound: Rôle non trouvé Member: diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index a4cc908fa2..60f3d87074 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -436,8 +436,6 @@ Errors: Invalid: A felhasználói jogosultság érvénytelen NotChanged: A felhasználói jogosultság nem lett módosítva IDMissing: Hiányzó azonosító - NotActive: A felhasználói jogosultság nem aktív - NotInactive: A felhasználói jogosultság nincs kikapcsolva NoPermissionForProject: A felhasználónak nincs jogosultsága ebben a projektben RoleKeyNotFound: Szerepkör nem található Member: diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index c9187020f7..2a9a8ee2c3 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Hibah pengguna tidak valid NotChanged: Hibah pengguna belum diubah IDMissing: Aku hilang - NotActive: Hibah pengguna tidak aktif - NotInactive: Hibah pengguna tidak dinonaktifkan NoPermissionForProject: Pengguna tidak memiliki izin pada proyek ini RoleKeyNotFound: Peran tidak ditemukan Member: diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index d1dccef4c7..cbae5543e2 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -436,8 +436,6 @@ Errors: Invalid: User Grant non è valido NotChanged: User Grant non è stata cambiato IDMissing: ID mancante - NotActive: User Grant non è attivo - NotInactive: User Grant non è disattivato NoPermissionForProject: L'utente non ha permessi su questo progetto RoleKeyNotFound: Ruolo non trovato Member: diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 4b0f2ea203..1d28dc16e5 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -437,8 +437,6 @@ Errors: Invalid: 無効なユーザーグラントです NotChanged: ユーザーグラントは変更されていません IDMissing: IDがありません - NotActive: ユーザーグラントはアクティブではありません - NotInactive: ユーザーグラントは非アクティブではありません NoPermissionForProject: ユーザーにはこのプロジェクトに許可がありません RoleKeyNotFound: ロールが見つかりません Member: diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index 2c87aa1f97..f0b19c6caa 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -437,8 +437,6 @@ Errors: Invalid: 사용자 권한이 유효하지 않습니다 NotChanged: 사용자 권한이 변경되지 않았습니다 IDMissing: ID가 누락되었습니다 - NotActive: 사용자 권한이 활성 상태가 아닙니다 - NotInactive: 사용자 권한이 비활성 상태가 아닙니다 NoPermissionForProject: 사용자가 이 프로젝트에 대한 권한이 없습니다 RoleKeyNotFound: 역할을 찾을 수 없습니다 Member: diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 64ae87a618..7d03f93ee0 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -435,8 +435,6 @@ Errors: Invalid: Овластувањето на корисникот е невалидно NotChanged: Овластувањето на корисникот не е променето IDMissing: ID недостасува - NotActive: Овластувањето на корисникот не е активно - NotInactive: Овластувањето на корисникот не е неактивно NoPermissionForProject: Корисникот нема овластувања за овој проект RoleKeyNotFound: Улогата не е пронајдена Member: diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index dc9fd83721..2fb1b9ac3b 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Gebruikerstoekenning is ongeldig NotChanged: Gebruikerstoekenning is niet veranderd IDMissing: ID ontbreekt - NotActive: Gebruikerstoekenning is niet actief - NotInactive: Gebruikerstoekenning is niet gedeactiveerd NoPermissionForProject: Gebruiker heeft geen rechten op dit project RoleKeyNotFound: Rol niet gevonden Member: diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 4952345510..a45173cf63 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Uprawnienie użytkownika jest nieprawidłowe NotChanged: Uprawnienie użytkownika nie zostało zmienione IDMissing: Brak ID - NotActive: Uprawnienie użytkownika nie jest aktywne - NotInactive: Uprawnienie użytkownika nie jest dezaktywowane NoPermissionForProject: Użytkownik nie ma uprawnień do tego projektu RoleKeyNotFound: Rola nie znaleziona Member: diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index e5fc785d0c..866f041e6e 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -435,8 +435,6 @@ Errors: Invalid: A concessão de usuário é inválida NotChanged: A concessão de usuário não foi alterada IDMissing: ID faltando - NotActive: A concessão de usuário não está ativa - NotInactive: A concessão de usuário não está desativada NoPermissionForProject: O usuário não possui permissões neste projeto RoleKeyNotFound: Função não encontrada Member: diff --git a/internal/static/i18n/ro.yaml b/internal/static/i18n/ro.yaml index ece4680de6..f56dfd13d2 100644 --- a/internal/static/i18n/ro.yaml +++ b/internal/static/i18n/ro.yaml @@ -437,8 +437,6 @@ Errors: Invalid: Acordarea utilizatorului este invalidă NotChanged: Acordarea utilizatorului nu a fost schimbată IDMissing: Id lipsă - NotActive: Acordarea utilizatorului nu este activă - NotInactive: Acordarea utilizatorului nu este dezactivată NoPermissionForProject: Utilizatorul nu are permisiuni pentru acest proiect RoleKeyNotFound: Rolul nu a fost găsit Member: diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index a2efd25322..34eb6b9837 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -430,8 +430,6 @@ Errors: Invalid: Допуск пользователя недействителен NotChanged: Допуск пользователя не был изменён IDMissing: ID отсутствует - NotActive: Допуск пользователя неактивен - NotInactive: Допуск пользователя не деактивирован NoPermissionForProject: Пользователь не имеет прав доступа к данному проекту RoleKeyNotFound: Роль не найдена Member: diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index be40ceba3c..c8c13e683e 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Användarbeviljandet är ogiltigt NotChanged: Användarbeviljandet har inte ändrats IDMissing: Id saknas - NotActive: Användarbeviljandet är inte aktivt - NotInactive: Användarbeviljandet är inte inaktivt NoPermissionForProject: Användaren har inga behörigheter i detta projekt RoleKeyNotFound: Rollen hittades inte Member: diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 930fcaddae..1ef3daadcd 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -436,8 +436,6 @@ Errors: Invalid: 用户授权无效 NotChanged: 用户授权未更改 IDMissing: 没有 ID - NotActive: 用户授权不是启用状态 - NotInactive: 用户授权不是停用状态 NoPermissionForProject: 用户对此项目没有权限 RoleKeyNotFound: 角色不存在 Member: diff --git a/internal/v2/system/event.go b/internal/v2/system/event.go new file mode 100644 index 0000000000..313c0fb293 --- /dev/null +++ b/internal/v2/system/event.go @@ -0,0 +1,44 @@ +package system + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, IDGeneratedType, eventstore.GenericEventMapper[IDGeneratedEvent]) +} + +const IDGeneratedType = AggregateType + ".id.generated" + +type IDGeneratedEvent struct { + eventstore.BaseEvent `json:"-"` + + ID string `json:"id"` +} + +func (e *IDGeneratedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = *b +} + +func (e *IDGeneratedEvent) Payload() interface{} { + return e +} + +func (e *IDGeneratedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewIDGeneratedEvent( + ctx context.Context, + id string, +) *IDGeneratedEvent { + return &IDGeneratedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + eventstore.NewAggregate(ctx, AggregateOwner, AggregateType, "v1"), + IDGeneratedType), + ID: id, + } +} diff --git a/internal/webauthn/converter.go b/internal/webauthn/converter.go index 36799ee3dc..c914bb8bf9 100644 --- a/internal/webauthn/converter.go +++ b/internal/webauthn/converter.go @@ -1,16 +1,26 @@ package webauthn import ( + "context" + "strings" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/domain" ) -func WebAuthNsToCredentials(webAuthNs []*domain.WebAuthNToken, rpID string) []webauthn.Credential { +func WebAuthNsToCredentials(ctx context.Context, webAuthNs []*domain.WebAuthNToken, rpID string) []webauthn.Credential { creds := make([]webauthn.Credential, 0) for _, webAuthN := range webAuthNs { - if webAuthN.State == domain.MFAStateReady && webAuthN.RPID == rpID { + // only add credentials that are ready and + // either match the rpID or + // if they were added through Console / old login UI, there is no stored rpID set; + // then we check if the requested rpID matches the instance domain + if webAuthN.State == domain.MFAStateReady && + (webAuthN.RPID == rpID || + (webAuthN.RPID == "" && rpID == strings.Split(http.DomainContext(ctx).InstanceHost, ":")[0])) { creds = append(creds, webauthn.Credential{ ID: webAuthN.KeyID, PublicKey: webAuthN.PublicKey, diff --git a/internal/webauthn/converter_test.go b/internal/webauthn/converter_test.go new file mode 100644 index 0000000000..a8f2a3608b --- /dev/null +++ b/internal/webauthn/converter_test.go @@ -0,0 +1,153 @@ +package webauthn + +import ( + "context" + "testing" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/domain" +) + +func TestWebAuthNsToCredentials(t *testing.T) { + type args struct { + ctx context.Context + webAuthNs []*domain.WebAuthNToken + rpID string + } + tests := []struct { + name string + args args + want []webauthn.Credential + }{ + { + name: "unready credential", + args: args{ + ctx: context.Background(), + webAuthNs: []*domain.WebAuthNToken{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AAGUID: []byte("aaguid1"), + SignCount: 1, + State: domain.MFAStateNotReady, + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{}, + }, + { + name: "not matching rpID", + args: args{ + ctx: context.Background(), + webAuthNs: []*domain.WebAuthNToken{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AAGUID: []byte("aaguid1"), + SignCount: 1, + State: domain.MFAStateReady, + RPID: "other.com", + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{}, + }, + { + name: "matching rpID", + args: args{ + ctx: context.Background(), + webAuthNs: []*domain.WebAuthNToken{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AAGUID: []byte("aaguid1"), + SignCount: 1, + State: domain.MFAStateReady, + RPID: "example.com", + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{ + { + ID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + Authenticator: webauthn.Authenticator{ + AAGUID: []byte("aaguid1"), + SignCount: 1, + }, + }, + }, + }, + { + name: "no rpID, different host", + args: args{ + ctx: http.WithDomainContext(context.Background(), &http.DomainCtx{ + InstanceHost: "other.com:443", + PublicHost: "other.com:443", + Protocol: "https", + }), + webAuthNs: []*domain.WebAuthNToken{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AAGUID: []byte("aaguid1"), + SignCount: 1, + State: domain.MFAStateReady, + RPID: "", + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{}, + }, + { + name: "no rpID, same host", + args: args{ + ctx: http.WithDomainContext(context.Background(), &http.DomainCtx{ + InstanceHost: "example.com:443", + PublicHost: "example.com:443", + Protocol: "https", + }), + webAuthNs: []*domain.WebAuthNToken{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AAGUID: []byte("aaguid1"), + SignCount: 1, + State: domain.MFAStateReady, + RPID: "", + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{ + { + ID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + Authenticator: webauthn.Authenticator{ + AAGUID: []byte("aaguid1"), + SignCount: 1, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, WebAuthNsToCredentials(tt.args.ctx, tt.args.webAuthNs, tt.args.rpID), "WebAuthNsToCredentials(%v, %v, %v)", tt.args.ctx, tt.args.webAuthNs, tt.args.rpID) + }) + } +} diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go index 998c013a3c..10d6fc52bf 100644 --- a/internal/webauthn/webauthn.go +++ b/internal/webauthn/webauthn.go @@ -57,7 +57,7 @@ func (w *Config) BeginRegistration(ctx context.Context, user *domain.Human, acco if err != nil { return nil, err } - creds := WebAuthNsToCredentials(webAuthNs, rpID) + creds := WebAuthNsToCredentials(ctx, webAuthNs, rpID) existing := make([]protocol.CredentialDescriptor, len(creds)) for i, cred := range creds { existing[i] = protocol.CredentialDescriptor{ @@ -136,7 +136,7 @@ func (w *Config) BeginLogin(ctx context.Context, user *domain.Human, userVerific } assertion, sessionData, err := webAuthNServer.BeginLogin(&webUser{ Human: user, - credentials: WebAuthNsToCredentials(webAuthNs, rpID), + credentials: WebAuthNsToCredentials(ctx, webAuthNs, rpID), }, webauthn.WithUserVerification(UserVerificationFromDomain(userVerification))) if err != nil { logging.WithFields("error", tryExtractProtocolErrMsg(err)).Debug("webauthn login could not be started") @@ -163,7 +163,7 @@ func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN * } webUser := &webUser{ Human: user, - credentials: WebAuthNsToCredentials(webAuthNs, webAuthN.RPID), + credentials: WebAuthNsToCredentials(ctx, webAuthNs, webAuthN.RPID), } webAuthNServer, err := w.serverFromContext(ctx, webAuthN.RPID, assertionData.Response.CollectedClientData.Origin) if err != nil { diff --git a/login/.changeset/README.md b/login/.changeset/README.md new file mode 100644 index 0000000000..e5b6d8d6a6 --- /dev/null +++ b/login/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/login/.changeset/config.json b/login/.changeset/config.json new file mode 100644 index 0000000000..3f2d313f66 --- /dev/null +++ b/login/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["@zitadel/login"] +} diff --git a/login/.eslintrc.cjs b/login/.eslintrc.cjs new file mode 100644 index 0000000000..1bfcec169d --- /dev/null +++ b/login/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + root: true, + // This tells ESLint to load the config from the package `@zitadel/eslint-config` + extends: ["@zitadel/eslint-config"], + settings: { + next: { + rootDir: ["apps/*/"], + }, + }, +}; diff --git a/login/.github/ISSUE_TEMPLATE/bug.yaml b/login/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000000..2764c1a365 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,63 @@ +name: 🐛 Bug Report +description: "Create a bug report to help us improve ZITADEL Typescript Library." +title: "[Bug]: " +labels: ["bug"] +body: +- type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! +- type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the documentation, the existing issues or discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + validations: + required: true +- type: input + id: version + attributes: + label: Version + description: Which version of ZITADEL Typescript Library are you using. +- type: textarea + id: impact + attributes: + label: Describe the problem caused by this bug + description: A clear and concise description of the problem you have and what the bug is. + validations: + required: true +- type: textarea + id: reproduce + attributes: + label: To reproduce + description: Steps to reproduce the behaviour + placeholder: | + Steps to reproduce the behavior: + 1. ... + validations: + required: true +- type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. +- type: textarea + id: expected + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. +- type: textarea + id: config + attributes: + label: Relevant Configuration + description: Add any relevant configurations that could help us. Make sure to redact any sensitive information. +- type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/config.yml b/login/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..7e690b9344 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +blank_issues_enabled: true +contact_links: + - name: 💬 ZITADEL Community Chat + url: https://zitadel.com/chat diff --git a/login/.github/ISSUE_TEMPLATE/docs.yaml b/login/.github/ISSUE_TEMPLATE/docs.yaml new file mode 100644 index 0000000000..04c1c0cdb1 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/docs.yaml @@ -0,0 +1,30 @@ +name: 📄 Documentation +description: Create an issue for missing or wrong documentation. +labels: ["docs"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this issue. + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: docs + attributes: + label: Describe the docs your are missing or that are wrong + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/improvement.yaml b/login/.github/ISSUE_TEMPLATE/improvement.yaml new file mode 100644 index 0000000000..cfe79d407b --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/improvement.yaml @@ -0,0 +1,54 @@ +name: 🛠️ Improvement +description: "Create an new issue for an improvment in ZITADEL" +labels: ["improvement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this improvement request + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: problem + attributes: + label: Describe your problem + description: Please describe your problem this improvement is supposed to solve. + placeholder: Describe the problem you have + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe your ideal solution + description: Which solution do you propose? + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Which version of the typescript library are you using. + - type: dropdown + id: environment + attributes: + label: Environment + description: How do you use ZITADEL? + options: + - ZITADEL Cloud + - Self-hosted + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/proposal.yaml b/login/.github/ISSUE_TEMPLATE/proposal.yaml new file mode 100644 index 0000000000..cd9ff66972 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/proposal.yaml @@ -0,0 +1,54 @@ +name: 💡 Proposal / Feature request +description: "Create an issue for a feature request/proposal." +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this proposal / feature reqeust + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: problem + attributes: + label: Describe your problem + description: Please describe your problem this proposal / feature is supposed to solve. + placeholder: Describe the problem you have. + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe your ideal solution + description: Which solution do you propose? + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Which version of the Typescript Library are you using. + - type: dropdown + id: environment + attributes: + label: Environment + description: How do you use ZITADEL? + options: + - ZITADEL Cloud + - Self-hosted + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/custom-i18n.png b/login/.github/custom-i18n.png new file mode 100644 index 0000000000..2306e62f87 Binary files /dev/null and b/login/.github/custom-i18n.png differ diff --git a/login/.github/dependabot.yml b/login/.github/dependabot.yml new file mode 100644 index 0000000000..8f3906c179 --- /dev/null +++ b/login/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: '/' + open-pull-requests-limit: 1 + schedule: + interval: 'daily' + + - package-ecosystem: npm + directory: '/' + open-pull-requests-limit: 3 + schedule: + interval: daily + groups: + prod: + dependency-type: production + dev: + dependency-type: development + ignore: + - dependency-name: "eslint" + versions: [ "9.x" ] diff --git a/login/.github/pull_request_template.md b/login/.github/pull_request_template.md new file mode 100644 index 0000000000..138d4919af --- /dev/null +++ b/login/.github/pull_request_template.md @@ -0,0 +1,13 @@ +### Definition of Ready + +- [ ] I am happy with the code +- [ ] Short description of the feature/issue is added in the pr description +- [ ] PR is linked to the corresponding user story +- [ ] Acceptance criteria are met +- [ ] All open todos and follow ups are defined in a new ticket and justified +- [ ] Deviations from the acceptance criteria and design are agreed with the PO and documented. +- [ ] Vitest unit tests ensure that components produce expected outputs on different inputs. +- [ ] Cypress integration tests ensure that login app pages work as expected on good and bad user inputs, ZITADEL responses or IDP redirects. The ZITADEL API is mocked, IDP redirects are simulated. +- [ ] Playwright acceptances tests ensure that the happy paths of common user journeys work as expected. The ZITADEL API is not mocked but IDP redirects are simulated. +- [ ] No debug or dead code +- [ ] My code has no repetitions diff --git a/login/.github/workflows/close_pr.yml b/login/.github/workflows/close_pr.yml new file mode 100644 index 0000000000..b44eb5bfe8 --- /dev/null +++ b/login/.github/workflows/close_pr.yml @@ -0,0 +1,35 @@ +name: Auto-close PRs and guide to correct repo + +on: + pull_request_target: + types: [opened] + +jobs: + auto-close: + runs-on: ubuntu-latest + if: github.repository_id == '622995060' + steps: + - name: Comment and close PR + uses: actions/github-script@v7 + with: + script: | + const message = ` + 👋 **Thanks for your contribution!** + + This repository \`${{ github.repository }}\` is a read-only mirror of our internal development in [\`zitadel/zitadel\`](https://github.com/zitadel/zitadel). + Therefore, we close this pull request automatically, but submitting your changes to the main repository is easy: + 1. Fork and clone zitadel/zitadel + 2. Create a new branch for your changes + 3. Pull your changes into the new fork by running `make login_pull LOGIN_REMOTE_URL=/typescript LOGIN_REMOTE_BRANCH=`. + 4. Push your changes and open a pull request to zitadel/zitadel + `.trim(); + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: message + }); + await github.rest.pulls.update({ + ...context.repo, + pull_number: context.issue.number, + state: "closed" + }); diff --git a/login/.github/workflows/issues.yml b/login/.github/workflows/issues.yml new file mode 100644 index 0000000000..ff12b8fe04 --- /dev/null +++ b/login/.github/workflows/issues.yml @@ -0,0 +1,41 @@ +name: Add new issues to product management project + +on: + issues: + types: + - opened + +jobs: + add-to-project: + name: Add issue and community pr to project + runs-on: ubuntu-latest + if: github.repository_id == '622995060' + steps: + - name: add issue + uses: actions/add-to-project@v1.0.2 + if: ${{ github.event_name == 'issues' }} + with: + # You can target a repository in a different organization + # to the issue + project-url: https://github.com/orgs/zitadel/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + - uses: tspascoal/get-user-teams-membership@v3 + id: checkUserMember + if: github.actor != 'dependabot[bot]' + with: + username: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} + - name: add pr + uses: actions/add-to-project@v1.0.2 + if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}} + with: + # You can target a repository in a different organization + # to the issue + project-url: https://github.com/orgs/zitadel/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + - uses: actions-ecosystem/action-add-labels@v1.1.3 + if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'staff')}} + with: + github_token: ${{ secrets.ADD_TO_PROJECT_PAT }} + labels: | + os-contribution diff --git a/login/.github/workflows/release.yml b/login/.github/workflows/release.yml new file mode 100644 index 0000000000..2508627d1b --- /dev/null +++ b/login/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + runs-on: ubuntu-latest + if: github.repository_id != '622995060' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install + + - name: Create Release Pull Request + uses: changesets/action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/login/.github/workflows/test.yml b/login/.github/workflows/test.yml new file mode 100644 index 0000000000..7b4721dbee --- /dev/null +++ b/login/.github/workflows/test.yml @@ -0,0 +1,67 @@ +name: Quality +on: + pull_request: + workflow_dispatch: + inputs: + ignore-run-cache: + description: 'Whether to ignore the run cache' + required: false + default: true + ref-tag: + description: 'overwrite the DOCKER_METADATA_OUTPUT_VERSION environment variable used by the make file' + required: false + default: '' +jobs: + quality: + name: Ensure Quality + if: github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.repository_id != '622995060') + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read # We only need read access to the repository contents + actions: write # We need write access to the actions cache + env: + CACHE_DIR: /tmp/login-run-caches + # Only run this job on workflow_dispatch or pushes to forks + steps: + - uses: actions/checkout@v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/zitadel/login + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + # Only with correctly restored build cache layers, the run caches work as expected. + # To restore docker build layer caches, extend the docker-bake.hcl to use the cache-from and cache-to options. + # https://docs.docker.com/build/ci/github-actions/cache/ + # Alternatively, you can use a self-hosted runner or a third-party builder that restores build layer caches out-of-the-box, like https://depot.dev/ + - name: Restore Run Caches + uses: actions/cache/restore@v4 + id: run-caches-restore + with: + path: ${{ env.CACHE_DIR }} + key: ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}-${{github.run_attempt}} + restore-keys: | + ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}- + ${{ runner.os }}-login-run-caches-${{github.ref_name}}- + ${{ runner.os }}-login-run-caches- + - run: make login_quality + env: + IGNORE_RUN_CACHE: ${{ github.event.inputs.ignore-run-cache == 'true' }} + DOCKER_METADATA_OUTPUT_VERSION: ${{ github.event.inputs.ref-tag || env.DOCKER_METADATA_OUTPUT_VERSION || steps.meta.outputs.version }} + - name: Save Run Caches + uses: actions/cache/save@v4 + with: + path: ${{ env.CACHE_DIR }} + key: ${{ steps.run-caches-restore.outputs.cache-primary-key }} + if: always() diff --git a/login/.gitignore b/login/.gitignore new file mode 100644 index 0000000000..8d49ae1b37 --- /dev/null +++ b/login/.gitignore @@ -0,0 +1,18 @@ +.DS_Store +node_modules +.turbo +*.log +.next +dist +dist-ssr +*.local +.env +server/dist +public/dist +.vscode +.idea +.vercel +.env*.local +/blob-report/ +/out +/docker diff --git a/login/.npmrc b/login/.npmrc new file mode 100644 index 0000000000..ded82e2f63 --- /dev/null +++ b/login/.npmrc @@ -0,0 +1 @@ +auto-install-peers = true diff --git a/login/.nvmrc b/login/.nvmrc new file mode 100644 index 0000000000..0a47c855eb --- /dev/null +++ b/login/.nvmrc @@ -0,0 +1 @@ +lts/iron \ No newline at end of file diff --git a/login/.prettierignore b/login/.prettierignore new file mode 100644 index 0000000000..77415caa1e --- /dev/null +++ b/login/.prettierignore @@ -0,0 +1,9 @@ +.next/ +.changeset/ +.github/ +dist/ +standalone/ +packages/zitadel-proto/google +packages/zitadel-proto/protoc-gen-openapiv2 +packages/zitadel-proto/validate +packages/zitadel-proto/zitadel diff --git a/login/.prettierrc b/login/.prettierrc new file mode 100644 index 0000000000..ba42405b03 --- /dev/null +++ b/login/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 125, + "trailingComma": "all", + "plugins": ["prettier-plugin-organize-imports"], + "filepath": "" +} diff --git a/login/CODE_OF_CONDUCT.md b/login/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..ac3f129652 --- /dev/null +++ b/login/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +legal@zitadel.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/login/CONTRIBUTING.md b/login/CONTRIBUTING.md new file mode 100644 index 0000000000..783935984f --- /dev/null +++ b/login/CONTRIBUTING.md @@ -0,0 +1,206 @@ +# Contributing + +:attention: In this CONTRIBUTING.md you read about contributing to this very repository. +If you want to develop your own login UI, please refer [to the README.md](./README.md). + +## Introduction + +Thank you for your interest about how to contribute! + +:attention: If you notice a possible **security vulnerability**, please don't hesitate to disclose any concern by contacting [security@zitadel.com](mailto:security@zitadel.com). +You don't have to be perfectly sure about the nature of the vulnerability. +We will give them a high priority and figure them out. + +We also appreciate all your other ideas, thoughts and feedback and will take care of them as soon as possible. +We love to discuss in an open space using [GitHub issues](https://github.com/zitadel/typescript/issues), +[GitHub discussions in the core repo](https://github.com/zitadel/zitadel/discussions) +or in our [chat on Discord](https://zitadel.com/chat). +For private discussions, +you have [more contact options on our Website](https://zitadel.com/contact). + +## Pull Requests + +Please consider the following guidelines when creating a pull request. + +- The latest changes are always in `main`, so please make your pull request against that branch. +- pull requests should be raised for any change +- Pull requests need approval of a Zitadel core engineer @zitadel/engineers before merging +- We use ESLint/Prettier for linting/formatting, so please run `pnpm lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [this Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to fix lint and formatting issues in development) +- If you add new functionality, please provide the corresponding documentation as well and make it part of the pull request + +### Setting up local environment + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server for the login and manually configure apps/login/.env.local +pnpm dev +``` + +The application is now available at `http://localhost:3000` + +Configure apps/login/.env.local to target the Zitadel instance of your choice. +The login app live-reloads on changes, so you can start developing right away. + +### Developing Against A Local Latest Zitadel Release + +The following command uses Docker to run a local Zitadel instance and the login application in live-reloading dev mode. +Additionally, it runs a Traefik reverse proxy that exposes the login with a self-signed certificate at https://127.0.0.1.sslip.io +127.0.0.1.sslip.io is a special domain that resolves to your localhost, so it's safe to allow your browser to proceed with loading the page. + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server and have apps/login/.env.test.local configured for you to target the local Zitadel instance. +pnpm dev:local +``` + +Log in at https://127.0.0.1.sslip.io/ui/v2/login/loginname and use the following credentials: +**Loginname**: *zitadel-admin@zitadel.127.0.0.1.sslip.io* +**Password**: _Password1!_. + +The login app live-reloads on changes, so you can start developing right away. + +### Developing Against A Locally Compiled Zitadel + +To develop against a locally compiled version of Zitadel, you need to build the Zitadel docker image first. +Clone the [Zitadel repository](https://github.com/zitadel/zitadel.git) and run the following command from its root: + +```sh +# This compiles a Zitadel binary if it does not exist at ./zitadel already and copies it into a Docker image. +# If you want to recompile the binary, run `make compile` first +make login_dev +``` + +Open another terminal session at zitadel/zitadel/login and run the following commands to start the dev server. + +```bash +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Start a local development server and have apps/login/.env.test.local configured for you to target the local Zitadel instance. +NODE_ENV=test pnpm dev +``` + +Log in at https://127.0.0.1.sslip.io/ui/v2/login/loginname and use the following credentials: +**Loginname**: *zitadel-admin@zitadel.127.0.0.1.sslip.io* +**Password**: _Password1!_. + +The login app live-reloads on changes, so you can start developing right away. + +### Quality Assurance + +Use `make` commands to test the quality of your code against a production build without installing any dependencies besides Docker. +Using `make` commands, you can reproduce and debug the CI pipelines locally. + +```sh +# Reproduce the whole CI pipeline in docker +make login_quality +# Show other options with make +make help +``` + +Use `pnpm` commands to run the tests in dev mode with live reloading and debugging capabilities. + +#### Linting and formatting + +Check the formatting and linting of the code in docker + +```sh +make login_lint +``` + +Check the linting of the code using pnpm + +```sh +pnpm lint +pnpm format +``` + +Fix the linting of your code + +```sh +pnpm lint:fix +pnpm format:fix +``` + +#### Running Unit Tests + +Run the tests in docker + +```sh +make login_test_unit +``` + +Run unit tests with live-reloading + +```sh +pnpm test:unit +``` + +#### Running Integration Tests + +Run the test in docker + +```sh +make login_test_integration +``` + +Alternatively, run a live-reloading development server with an interactive Cypress test suite. +First, set up your local test environment. + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server and use apps/login/.env.test to use the locally mocked Zitadel API. +pnpm test:integration:setup +``` + +Now, in another terminal session, open the interactive Cypress integration test suite. + +```sh +pnpm test:integration open +``` + +Show more options with Cypress + +```sh +pnpm test:integration help +``` + +#### Running Acceptance Tests + +To run the tests in docker against the latest release of Zitadel, use the following command: + +:warning: The acceptance tests are not reliable at the moment :construction: + +```sh +make login_test_acceptance +``` + +Alternatively, run can use a live-reloading development server with an interactive Playwright test suite. +Set up your local environment by running the commands either for [developing against a local latest Zitadel release](latest) or for [developing against a locally compiled Zitadel](compiled). + +Now, in another terminal session, open the interactive Playwright acceptance test suite. + +```sh +pnpm test:acceptance open +``` + +Show more options with Playwright + +```sh +pnpm test:acceptance help +``` diff --git a/login/LICENSE b/login/LICENSE new file mode 100644 index 0000000000..89f750f2ab --- /dev/null +++ b/login/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 ZITADEL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/login/Makefile b/login/Makefile new file mode 100644 index 0000000000..05cf704c3f --- /dev/null +++ b/login/Makefile @@ -0,0 +1,137 @@ +XDG_CACHE_HOME ?= $(HOME)/.cache +export CACHE_DIR ?= $(XDG_CACHE_HOME)/zitadel-make + +LOGIN_DIR ?= ./ +LOGIN_BAKE_CLI ?= docker buildx bake +LOGIN_BAKE_CLI_WITH_ARGS := $(LOGIN_BAKE_CLI) --file $(LOGIN_DIR)docker-bake.hcl --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml +LOGIN_BAKE_CLI_ADDITIONAL_ARGS ?= +LOGIN_BAKE_CLI_WITH_ARGS += $(LOGIN_BAKE_CLI_ADDITIONAL_ARGS) + +export COMPOSE_BAKE=true +export UID := $(id -u) +export GID := $(id -g) + +export LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT := $(LOGIN_DIR)apps/login-test-acceptance + +export DOCKER_METADATA_OUTPUT_VERSION ?= local +export LOGIN_TAG ?= zitadel-login:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_UNIT_TAG := login-test-unit:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_INTEGRATION_TAG := login-test-integration:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_TAG := login-test-acceptance:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SETUP_TAG := login-test-acceptance-setup:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SINK_TAG := login-test-acceptance-sink:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_OIDCRP_TAG := login-test-acceptance-oidcrp:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_OIDCOP_TAG := login-test-acceptance-oidcop:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SAMLSP_TAG := login-test-acceptance-samlsp:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG := login-test-acceptance-samlidp:${DOCKER_METADATA_OUTPUT_VERSION} +export POSTGRES_TAG := postgres:17.0-alpine3.19 +export GOLANG_TAG := golang:1.24-alpine +export ZITADEL_TAG ?= ghcr.io/zitadel/zitadel:latest +export LOGIN_CORE_MOCK_TAG := login-core-mock:${DOCKER_METADATA_OUTPUT_VERSION} + +login_help: + @echo "Makefile for the login service" + @echo "Available targets:" + @echo " login_help - Show this help message." + @echo " login_quality - Run all quality checks (login_lint, login_test_unit, login_test_integration, login_test_acceptance)." + @echo " login_standalone_build - Build the docker image for production login containers." + @echo " login_lint - Run linting and formatting checks. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login_test_unit - Run unit tests. Tests without any dependencies. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login-test_integration - Run integration tests. Tests a login production build against a mocked Zitadel core API. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login_test_acceptance - Run acceptance tests. Tests a login production build with a local Zitadel instance behind a reverse proxy. IGNORE_RUN_CACHE=true prevents skipping." + @echo " typescript_generate - Generate TypeScript client code from Protobuf definitions." + @echo " show_run_caches - Show all run caches with image ids and exit codes." + @echo " clean_run_caches - Remove all run caches." + + +login_lint: + @echo "Running login linting and formatting checks" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-lint + +login_test_unit: + @echo "Running login unit tests" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-test-unit + +login_test_integration_build: + @echo "Building login integration test environment with the local core mock image" + $(LOGIN_BAKE_CLI_WITH_ARGS) core-mock login-test-integration login-standalone --load + +login_test_integration_dev: login_test_integration_cleanup + @echo "Starting login integration test environment with the local core mock image" + $(LOGIN_BAKE_CLI_WITH_ARGS) core-mock && docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml run --service-ports --rm core-mock + +login_test_integration_run: login_test_integration_cleanup + @echo "Running login integration tests" + docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml run --rm integration + +login_test_integration_cleanup: + @echo "Cleaning up login integration test environment" + docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml down --volumes + +login_test_integration: login_test_integration_build + $(LOGIN_DIR)scripts/run_or_skip.sh login_test_integration_run \ + "$(LOGIN_TAG) \ + $(LOGIN_CORE_MOCK_TAG) \ + $(LOGIN_TEST_INTEGRATION_TAG)" + +login_test_acceptance_build_bake: + @echo "Building login test acceptance images as defined in the docker-bake.hcl" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-test-acceptance login-standalone --load + +login_test_acceptance_build_compose: + @echo "Building login test acceptance images as defined in the docker-compose.yaml" + $(LOGIN_BAKE_CLI_WITH_ARGS) --load setup sink + +# login_test_acceptance_build is overwritten by the login_dev target in zitadel/zitadel/Makefile +login_test_acceptance_build: login_test_acceptance_build_compose login_test_acceptance_build_bake + +login_test_acceptance_run: login_test_acceptance_cleanup + @echo "Running login test acceptance tests" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose-ci.yaml run --rm --service-ports acceptance + +login_test_acceptance_cleanup: + @echo "Cleaning up login test acceptance environment" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose-ci.yaml down --volumes + +login_test_acceptance: login_test_acceptance_build + $(LOGIN_DIR)scripts/run_or_skip.sh login_test_acceptance_run \ + "$(LOGIN_TAG) \ + $(ZITADEL_TAG) \ + $(POSTGRES_TAG) \ + $(GOLANG_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_SETUP_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_SINK_TAG)" + +login_test_acceptance_setup_env: login_test_acceptance_build_compose login_test_acceptance_cleanup + @echo "Setting up the login test acceptance environment and writing the env.test.local file" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml run setup + +login_test_acceptance_setup_dev: + @echo "Starting the login test acceptance environment with the local zitadel image" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml up --no-recreate zitadel traefik sink + +login_quality: login_lint login_test_unit login_test_integration + @echo "Running login quality checks: lint, unit tests, integration tests" + +login_standalone_build: + @echo "Building the login standalone docker image with tag: $(LOGIN_TAG)" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-standalone --load + +login_standalone_out: + $(LOGIN_BAKE_CLI_WITH_ARGS) login-standalone-out + +typescript_generate: + @echo "Generating TypeScript client and writing to local $(LOGIN_DIR)packages/zitadel-proto" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-typescript-proto-client-out + +clean_run_caches: + @echo "Removing cache directory: $(CACHE_DIR)" + rm -rf "$(CACHE_DIR)" + +show_run_caches: + @echo "Showing run caches with docker image ids and exit codes in $(CACHE_DIR):" + @find "$(CACHE_DIR)" -type f 2>/dev/null | while read file; do \ + echo "$$file: $$(cat $$file)"; \ + done + diff --git a/login/README.md b/login/README.md new file mode 100644 index 0000000000..c3601e666b --- /dev/null +++ b/login/README.md @@ -0,0 +1,264 @@ +# ZITADEL TypeScript with Turborepo + +This repository contains all TypeScript and JavaScript packages and applications you need to create your own ZITADEL +Login UI. + +collage of login screens + +[![npm package](https://img.shields.io/npm/v/@zitadel/proto.svg?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@zitadel/proto) +[![npm package](https://img.shields.io/npm/v/@zitadel/client.svg?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@zitadel/client) + +**⚠️ This repo and packages are in beta state and subject to change ⚠️** + +The scope of functionality of this repo and packages is under active development. + +The `@zitadel/client` package is using [@connectrpc/connect](https://github.com/connectrpc/connect-es#readme). + +You can read the [contribution guide](/CONTRIBUTING.md) on how to contribute. +Questions can be raised in our [Discord channel](https://discord.gg/erh5Brh7jE) or as +a [GitHub issue](https://github.com/zitadel/typescript/issues). + +## Developing Your Own ZITADEL Login UI + +We think the easiest path of getting up and running, is the following: + +1. Fork and clone this repository +1. [Run the ZITADEL Cloud login UI locally](#run-login-ui) +1. Make changes to the code and see the effects live on your local machine +1. Study the rest of this README.md and get familiar and comfortable with how everything works. +1. Decide on a way of how you want to build and run your login UI. + You can reuse ZITADEL Clouds way. + But if you need more freedom, you can also import the packages you need into your self built application. + +## Included Apps And Packages + +- `login`: The login UI used by ZITADEL Cloud, powered by Next.js +- `@zitadel/client`: shared client utilities for node and browser environments +- `@zitadel/proto`: Protocol Buffers (proto) definitions used by ZITADEL projects +- `@zitadel/tsconfig`: shared `tsconfig.json`s used throughout the monorepo +- `@zitadel/eslint-config`: ESLint preset + +Each package and app is 100% [TypeScript](https://www.typescriptlang.org/). + +### Login + +The login is currently in a work in progress state. +The goal is to implement a login UI, using the session API of ZITADEL, which also implements the OIDC Standard and is +ready to use for everyone. + +In the first phase we want to have a MVP login ready with the OIDC Standard and a basic feature set. In a second step +the features will be extended. + +This list should show the current implementation state, and also what is missing. +You can already use the current state, and extend it with your needs. + +#### Features list + +- [x] Local User Registration (with Password) +- [x] User Registration and Login with external Provider + - [x] Google + - [x] GitHub + - [x] GitHub Enterprise + - [x] GitLab + - [x] GitLab Enterprise + - [x] Azure + - [x] Apple + - [x] Generic OIDC + - [x] Generic OAuth + - [x] Generic JWT + - [x] LDAP + - [x] SAML SP +- Multifactor Registration an Login + - [x] Passkeys + - [x] TOTP + - [x] OTP: Email Code + - [x] OTP: SMS Code +- [x] Password Change/Reset +- [x] Domain Discovery +- [x] Branding +- OIDC Standard + + - [x] Authorization Code Flow with PKCE + - [x] AuthRequest `hintUserId` + - [x] AuthRequest `loginHint` + - [x] AuthRequest `prompt` + - [x] Login + - [x] Select Account + - [ ] Consent + - [x] Create + - Scopes + - [x] `openid email profile address`` + - [x] `offline access` + - [x] `urn:zitadel:iam:org:idp:id:{idp_id}` + - [x] `urn:zitadel:iam:org:project:id:zitadel:aud` + - [x] `urn:zitadel:iam:org:id:{orgid}` + - [x] `urn:zitadel:iam:org:domain:primary:{domain}` + - [ ] AuthRequest UI locales + + #### Flow diagram + + This diagram shows the available pages and flows. + + > Note that back navigation or retries are not displayed. + +```mermaid + flowchart TD + A[Start] --> register + A[Start] --> accounts + A[Start] --> loginname + loginname -- signInWithIDP --> idp-success + loginname -- signInWithIDP --> idp-failure + idp-success --> B[signedin] + loginname --> password + loginname -- hasPasskey --> passkey + loginname -- allowRegister --> register + passkey-add --passwordAllowed --> password + passkey -- hasPassword --> password + passkey --> B[signedin] + password -- hasMFA --> mfa + password -- allowPasskeys --> passkey-add + password -- reset --> password-set + email -- reset --> password-set + password-set --> B[signedin] + password-change --> B[signedin] + password -- userstate=initial --> password-change + + mfa --> otp + otp --> B[signedin] + mfa--> u2f + u2f -->B[signedin] + register -- password/passkey --> B[signedin] + password --> B[signedin] + password-- forceMFA -->mfaset + mfaset --> u2fset + mfaset --> otpset + u2fset --> B[signedin] + otpset --> B[signedin] + accounts--> loginname + password -- not verified yet -->verify + register-- withpassword -->verify + passkey-- notVerified --> verify + verify --> B[signedin] +``` + +You can find a more detailed documentation of the different pages [here](./apps/login/readme.md). + +#### Custom translations + +The new login uses the [SettingsApi](https://zitadel.com/docs/apis/resources/settings_service_v2/settings-service-get-hosted-login-translation) to load custom translations. +Translations can be overriden at both the instance and organization levels. +To find the keys more easily, you can inspect the HTML and search for a `data-i18n-key` attribute, or look at the defaults in `/apps/login/locales/[locale].ts`. +![Custom Translations](.github/custom-i18n.png) + +## Tooling + +- [TypeScript](https://www.typescriptlang.org/) for static type checking +- [ESLint](https://eslint.org/) for code linting +- [Prettier](https://prettier.io) for code formatting + +## Useful Commands + +- `make login-quality` - Check the quality of your code against a production build without installing any dependencies besides Docker +- `pnpm generate` - Build proto stubs for the client package +- `pnpm dev` - Develop all packages and the login app +- `pnpm build` - Build all packages and the login app +- `pnpm clean` - Clean up all `node_modules` and `dist` folders (runs each package's clean script) + +Learn more about developing the login UI in the [contribution guide](/CONTRIBUTING.md). + +## Versioning And Publishing Packages + +Package publishing has been configured using [Changesets](https://github.com/changesets/changesets). +Here is their [documentation](https://github.com/changesets/changesets#documentation) for more information about the +workflow. + +The [GitHub Action](https://github.com/changesets/action) needs an `NPM_TOKEN` and `GITHUB_TOKEN` in the repository +settings. The [Changesets bot](https://github.com/apps/changeset-bot) should also be installed on the GitHub repository. + +Read the [changesets documentation](https://github.com/changesets/changesets/blob/main/docs/automating-changesets.md) +for more information about this automation + +### Run Login UI + +To run the application make sure to install the dependencies with + +```sh +pnpm install +``` + +then generate the GRPC stubs with + +```sh +pnpm generate +``` + +To run the application against a local ZITADEL instance, run the following command: + +```sh +pnpm run-zitadel +``` + +This sets up ZITADEL using docker compose and writes the configuration to the file `apps/login/.env.local`. + +
+Alternatively, use another environment +You can develop against any ZITADEL instance in which you have sufficient rights to execute the following steps. +Just create or overwrite the file `apps/login/.env.local` yourself. +Add your instances base URL to the file at the key `ZITADEL_API_URL`. +Go to your instance and create a service user for the login application. +The login application creates users on your primary organization and reads policy data. +For the sake of simplicity, just make the service user an instance member with the role `IAM_OWNER`. +Create a PAT and copy it to the file `apps/login/.env.local` using the key `ZITADEL_SERVICE_USER_TOKEN`. + +The file should look similar to this: + +``` +ZITADEL_API_URL=https://zitadel-tlx3du.us1.zitadel.cloud +ZITADEL_SERVICE_USER_TOKEN=1S6w48thfWFI2klgfwkCnhXJLf9FQ457E-_3H74ePQxfO3Af0Tm4V5Xi-ji7urIl_xbn-Rk +``` + +
+ +Start the login application in dev mode: + +```sh +pnpm dev +``` + +Open the login application with your favorite browser at `localhost:3000`. +Change the source code and see the changes live in your browser. + +Make sure the application still behaves as expected by running all tests + +```sh +pnpm test +``` + +To satisfy your unique workflow requirements, check out the package.json in the root directory for more detailed scripts. + +### Run Login UI Acceptance tests + +To run the acceptance tests you need a running ZITADEL environment and a component which receives HTTP requests for the emails and sms's. +This component should also be able to return the content of these notifications, as the codes and links are used in the login flows. +There is a basic implementation in Golang available under [the sink package](./acceptance/sink). + +To setup ZITADEL with the additional Sink container for handling the notifications: + +```sh +pnpm run-sink +``` + +Then you can start the acceptance tests with: + +```sh +pnpm test:acceptance +``` + +### Deploy to Vercel + +To deploy your own version on Vercel, navigate to your instance and create a service user. +Then create a personal access token (PAT), copy and set it as ZITADEL_SERVICE_USER_TOKEN, then navigate to your instance +settings and make sure it gets IAM_OWNER permissions. +Finally set your instance url as ZITADEL_API_URL. Make sure to set it without trailing slash. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzitadel%2Ftypescript&env=ZITADEL_API_URL,ZITADEL_SERVICE_USER_TOKEN&root-directory=apps/login&envDescription=Setup%20a%20service%20account%20with%20IAM_LOGIN_CLIENT%20membership%20on%20your%20instance%20and%20provide%20its%20personal%20access%20token.&project-name=zitadel-login&repository-name=zitadel-login) diff --git a/login/acceptance/docker-compose.yaml b/login/acceptance/docker-compose.yaml new file mode 100644 index 0000000000..a68a435e83 --- /dev/null +++ b/login/acceptance/docker-compose.yaml @@ -0,0 +1,71 @@ +services: + zitadel: + user: "${ZITADEL_DEV_UID}" + image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:02617cf17fdde849378c1a6b5254bbfb2745b164}" + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml' + ports: + - "8080:8080" + volumes: + - ./pat:/pat + - ./zitadel.yaml:/zitadel.yaml + depends_on: + db: + condition: "service_healthy" + extra_hosts: + - "localhost:host-gateway" + + db: + restart: "always" + image: postgres:17.0-alpine3.19 + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - 5432:5432 + + wait_for_zitadel: + image: curlimages/curl:8.00.1 + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false + depends_on: + - zitadel + + setup: + user: "${ZITADEL_DEV_UID}" + container_name: setup + image: acceptance-setup:latest + environment: + PAT_FILE: /pat/zitadel-admin-sa.pat + ZITADEL_API_INTERNAL_URL: http://zitadel:8080 + WRITE_ENVIRONMENT_FILE: /apps/login/.env.local + WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local + SINK_EMAIL_INTERNAL_URL: http://sink:3333/email + SINK_SMS_INTERNAL_URL: http://sink:3333/sms + SINK_NOTIFICATION_URL: http://localhost:3333/notification + volumes: + - "./pat:/pat" + - "../apps/login:/apps/login" + - "../acceptance/tests:/acceptance/tests" + depends_on: + wait_for_zitadel: + condition: "service_completed_successfully" + + sink: + image: golang:1.24-alpine + container_name: sink + command: go run /sink/main.go -port '3333' -email '/email' -sms '/sms' -notification '/notification' + ports: + - 3333:3333 + volumes: + - "./sink:/sink" + depends_on: + setup: + condition: "service_completed_successfully" diff --git a/login/apps/login-test-acceptance/.gitignore b/login/apps/login-test-acceptance/.gitignore new file mode 100644 index 0000000000..6a7425e885 --- /dev/null +++ b/login/apps/login-test-acceptance/.gitignore @@ -0,0 +1 @@ +go-command diff --git a/login/apps/login-test-acceptance/docker-compose-ci.yaml b/login/apps/login-test-acceptance/docker-compose-ci.yaml new file mode 100644 index 0000000000..6f5963df43 --- /dev/null +++ b/login/apps/login-test-acceptance/docker-compose-ci.yaml @@ -0,0 +1,59 @@ +services: + + zitadel: + environment: + ZITADEL_EXTERNALDOMAIN: traefik + + traefik: + labels: !reset [] + + setup: + environment: + ZITADEL_API_DOMAIN: traefik + ZITADEL_API_URL: https://traefik + LOGIN_BASE_URL: https://traefik/ui/v2/login/ + SINK_NOTIFICATION_URL: http://sink:3333/notification + ZITADEL_ADMIN_USER: zitadel-admin@zitadel.traefik + + login: + image: "${LOGIN_TAG:-zitadel-login:local}" + container_name: acceptance-login + labels: + - "traefik.enable=true" + - "traefik.http.routers.login.rule=PathPrefix(`/ui/v2/login`)" + ports: + - "3000:3000" + environment: + - NODE_TLS_REJECT_UNAUTHORIZED=0 + depends_on: + setup: + condition: service_completed_successfully + + acceptance: + image: "${LOGIN_TEST_ACCEPTANCE_TAG:-login-test-acceptance:local}" + container_name: acceptance + environment: + - CI + - LOGIN_BASE_URL=https://traefik/ui/v2/login/ + - NODE_TLS_REJECT_UNAUTHORIZED=0 + volumes: + - ../login/.env.test.local:/build/apps/login/.env.test.local + - ./test-results:/build/apps/login-test-acceptance/test-results + - ./playwright-report:/build/apps/login-test-acceptance/playwright-report + ports: + - 9323:9323 + ipc: "host" + init: true + depends_on: + login: + condition: "service_healthy" + sink: + condition: service_healthy +# oidcrp: +# condition: service_healthy +# oidcop: +# condition: service_healthy +# samlsp: +# condition: service_healthy +# samlidp: +# condition: service_healthy diff --git a/login/apps/login-test-acceptance/docker-compose.yaml b/login/apps/login-test-acceptance/docker-compose.yaml new file mode 100644 index 0000000000..cb0463fdc8 --- /dev/null +++ b/login/apps/login-test-acceptance/docker-compose.yaml @@ -0,0 +1,237 @@ +services: + + zitadel: + user: "${UID:-1000}:${GID:-1000}" + image: "${ZITADEL_TAG:-ghcr.io/zitadel/zitadel:latest}" + container_name: acceptance-zitadel + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --config /zitadel.yaml --steps /zitadel.yaml' + labels: + - "traefik.enable=true" + - "traefik.http.routers.zitadel.rule=!PathPrefix(`/ui/v2/login`)" + # - "traefik.http.middlewares.zitadel.headers.customrequestheaders.Host=localhost" +# - "traefik.http.routers.zitadel.middlewares=zitadel@docker" + - "traefik.http.services.zitadel-service.loadbalancer.server.scheme=h2c" + ports: + - "8080:8080" + volumes: + - ./pat:/pat + - ./zitadel.yaml:/zitadel.yaml + depends_on: + db: + condition: "service_healthy" + + db: + restart: "always" + image: ${LOGIN_TEST_ACCEPTANCE_POSTGES_TAG:-postgres:17.0-alpine3.19} + container_name: acceptance-db + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - "5432:5432" + + wait-for-zitadel: + image: curlimages/curl:8.00.1 + container_name: acceptance-wait-for-zitadel + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false + depends_on: + - zitadel + + traefik: + image: "traefik:v3.4" + container_name: "acceptance-traefik" + labels: + - "traefik.enable=true" + - "traefik.http.routers.login.rule=PathPrefix(`/ui/v2/login`)" + - "traefik.http.services.login-service.loadbalancer.server.url=http://host.docker.internal:3000" + command: +# - "--log.level=DEBUG" + - "--ping" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.websecure.http.tls=true" + - "--entryPoints.websecure.address=:443" + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - "443:443" + - "8090:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + extra_hosts: + - host.docker.internal:host-gateway + + setup: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_SETUP_TAG:-login-test-acceptance-setup:local} + container_name: acceptance-setup + restart: no + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/setup" + dockerfile: ../go-command.Dockerfile + entrypoint: "./setup.sh" + environment: + PAT_FILE: /pat/zitadel-admin-sa.pat + ZITADEL_API_INTERNAL_URL: http://zitadel:8080 + WRITE_ENVIRONMENT_FILE: /login-env/.env.test.local + SINK_EMAIL_INTERNAL_URL: http://sink:3333/email + SINK_SMS_INTERNAL_URL: http://sink:3333/sms + SINK_NOTIFICATION_URL: http://localhost:3333/notification + LOGIN_BASE_URL: https://127.0.0.1.sslip.io/ui/v2/login/ + ZITADEL_API_URL: https://127.0.0.1.sslip.io + ZITADEL_API_DOMAIN: 127.0.0.1.sslip.io + ZITADEL_ADMIN_USER: zitadel-admin@zitadel.127.0.0.1.sslip.io + volumes: + - ./pat:/pat # Read the PAT file from zitadels setup + - ../login:/login-env # Write the environment variables file for the login + depends_on: + traefik: + condition: "service_healthy" + wait-for-zitadel: + condition: "service_completed_successfully" + + sink: + image: ${LOGIN_TEST_ACCEPTANCE_SINK_TAG:-login-test-acceptance-sink:local} + container_name: acceptance-sink + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/sink" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + PORT: '3333' + command: + - -port + - '3333' + - -email + - '/email' + - -sms + - '/sms' + - -notification + - '/notification' + ports: + - "3333:3333" + depends_on: + setup: + condition: "service_completed_successfully" + + oidcrp: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_OIDCRP_TAG:-login-test-acceptance-oidcrp:local} + container_name: acceptance-oidcrp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/oidcrp" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'https://traefik/ui/v2/login' + ISSUER: 'https://traefik' + HOST: 'traefik' + PORT: '8000' + SCOPES: 'openid profile email' + ports: + - "8000:8000" + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + oidcop: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_OIDCOP_TAG:-login-test-acceptance-oidcop:local} + container_name: acceptance-oidcop + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/idp/oidc" + dockerfile: ../../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + SCHEMA: 'https' + HOST: 'traefik' + PORT: "8004" + ports: + - 8004:8004 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + samlsp: + user: "${UID:-1000}:${GID:-1000}" + image: "${LOGIN_TEST_ACCEPTANCE_SAMLSP_TAG:-login-test-acceptance-samlsp:local}" + container_name: acceptance-samlsp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/samlsp" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'https://traefik/ui/v2/login' + IDP_URL: 'http://zitadel:8080/saml/v2/metadata' + HOST: 'https://traefik' + PORT: '8001' + ports: + - 8001:8001 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + samlidp: + user: "${UID:-1000}:${GID:-1000}" + image: "${LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG:-login-test-acceptance-samlidp:local}" + container_name: acceptance-samlidp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/idp/saml" + dockerfile: ../../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik:8080' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + SCHEMA: 'https' + HOST: 'traefik' + PORT: "8003" + ports: + - 8003:8003 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" diff --git a/login/apps/login-test-acceptance/go-command.Dockerfile b/login/apps/login-test-acceptance/go-command.Dockerfile new file mode 100644 index 0000000000..fafebd6f4d --- /dev/null +++ b/login/apps/login-test-acceptance/go-command.Dockerfile @@ -0,0 +1,11 @@ +ARG LOGIN_TEST_ACCEPTANCE_GOLANG_TAG="golang:1.24-alpine" + +FROM ${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG} +RUN apk add curl jq +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o /go-command . +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s \ + CMD curl -f http://localhost:${PORT}/healthy || exit 1 +ENTRYPOINT [ "/go-command" ] diff --git a/login/apps/login-test-acceptance/idp/oidc/go.mod b/login/apps/login-test-acceptance/idp/oidc/go.mod new file mode 100644 index 0000000000..bc43390218 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/go.mod @@ -0,0 +1,28 @@ +module github.com/zitadel/typescript/acceptance/idp/oidc + +go 1.24.1 + +require github.com/zitadel/oidc/v3 v3.37.0 + +require ( + github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect + github.com/go-chi/chi/v5 v5.2.2 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/muhlemmer/httpforwarded v0.1.0 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/zitadel/logging v0.6.2 // indirect + github.com/zitadel/schema v1.3.1 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/login/apps/login-test-acceptance/idp/oidc/go.sum b/login/apps/login-test-acceptance/idp/oidc/go.sum new file mode 100644 index 0000000000..23fd2b3384 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/go.sum @@ -0,0 +1,71 @@ +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= +github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= +github.com/zitadel/oidc/v3 v3.37.0 h1:nYATWlnP7f18XiAbw6upUruBaqfB1kUrXrSTf1EYGO8= +github.com/zitadel/oidc/v3 v3.37.0/go.mod h1:/xDan4OUQhguJ4Ur73OOJrtugvR164OMnidXP9xfVNw= +github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= +github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/apps/login-test-acceptance/idp/oidc/main.go b/login/apps/login-test-acceptance/idp/oidc/main.go new file mode 100644 index 0000000000..b04ac94234 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/zitadel/oidc/v3/example/server/exampleop" + "github.com/zitadel/oidc/v3/example/server/storage" +) + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + schema := os.Getenv("SCHEMA") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }), + ) + + issuer := fmt.Sprintf("%s://%s:%s/", schema, host, port) + redirectURI := fmt.Sprintf("%s/idps/callback", apiURL) + + clientID := "web" + clientSecret := "secret" + storage.RegisterClients( + storage.WebClient(clientID, clientSecret, redirectURI), + ) + + storage := storage.NewStorage(storage.NewUserStore(issuer)) + router := exampleop.SetupServer(issuer, storage, logger, false) + + server := &http.Server{ + Addr: ":" + port, + Handler: router, + } + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + createZitadelResources(apiURL, pat, domain, issuer, clientID, clientSecret) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain, issuer, clientID, clientSecret string) error { + idpID, err := CreateIDP(apiURL, pat, domain, issuer, clientID, clientSecret) + if err != nil { + return err + } + return ActivateIDP(apiURL, pat, domain, idpID) +} + +type createIDP struct { + Name string `json:"name"` + Issuer string `json:"issuer"` + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + Scopes []string `json:"scopes"` + ProviderOptions providerOptions `json:"providerOptions"` + IsIdTokenMapping bool `json:"isIdTokenMapping"` + UsePkce bool `json:"usePkce"` +} + +type providerOptions struct { + IsLinkingAllowed bool `json:"isLinkingAllowed"` + IsCreationAllowed bool `json:"isCreationAllowed"` + IsAutoCreation bool `json:"isAutoCreation"` + IsAutoUpdate bool `json:"isAutoUpdate"` + AutoLinking string `json:"autoLinking"` +} + +type idp struct { + ID string `json:"id"` +} + +func CreateIDP(apiURL, pat, domain string, issuer, clientID, clientSecret string) (string, error) { + createIDP := &createIDP{ + Name: "OIDC", + Issuer: issuer, + ClientId: clientID, + ClientSecret: clientSecret, + Scopes: []string{"openid", "profile", "email"}, + ProviderOptions: providerOptions{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: "AUTO_LINKING_OPTION_USERNAME", + }, + IsIdTokenMapping: false, + UsePkce: false, + } + + resp, err := doRequestWithHeaders(apiURL+"/admin/v1/idps/generic_oidc", pat, domain, createIDP) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + idp := new(idp) + if err := json.Unmarshal(data, idp); err != nil { + return "", err + } + return idp.ID, nil +} + +type activateIDP struct { + IdpId string `json:"idpId"` +} + +func ActivateIDP(apiURL, pat, domain string, idpID string) error { + activateIDP := &activateIDP{ + IdpId: idpID, + } + _, err := doRequestWithHeaders(apiURL+"/admin/v1/policies/login/idps", pat, domain, activateIDP) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/idp/saml/go.mod b/login/apps/login-test-acceptance/idp/saml/go.mod new file mode 100644 index 0000000000..e73b4feb3b --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/go.mod @@ -0,0 +1,16 @@ +module github.com/zitadel/typescript/acceptance/idp/saml + +go 1.24.1 + +require ( + github.com/crewjam/saml v0.4.14 + github.com/mattermost/xml-roundtrip-validator v0.1.0 + github.com/zenazn/goji v1.0.1 + golang.org/x/crypto v0.36.0 +) + +require ( + github.com/beevik/etree v1.1.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/russellhaering/goxmldsig v1.3.0 // indirect +) diff --git a/login/apps/login-test-acceptance/idp/saml/go.sum b/login/apps/login-test-acceptance/idp/saml/go.sum new file mode 100644 index 0000000000..1208550f6e --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/go.sum @@ -0,0 +1,49 @@ +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= +github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= +github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/login/apps/login-test-acceptance/idp/saml/main.go b/login/apps/login-test-acceptance/idp/saml/main.go new file mode 100644 index 0000000000..059eab79e2 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/main.go @@ -0,0 +1,328 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "encoding/xml" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/crewjam/saml" + "github.com/crewjam/saml/logger" + "github.com/crewjam/saml/samlidp" + xrv "github.com/mattermost/xml-roundtrip-validator" + "github.com/zenazn/goji" + "github.com/zenazn/goji/bind" + "github.com/zenazn/goji/web" + "golang.org/x/crypto/bcrypt" +) + +var key = func() crypto.PrivateKey { + b, _ := pem.Decode([]byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0OhbMuizgtbFOfwbK7aURuXhZx6VRuAs3nNibiuifwCGz6u9 +yy7bOR0P+zqN0YkjxaokqFgra7rXKCdeABmoLqCC0U+cGmLNwPOOA0PaD5q5xKhQ +4Me3rt/R9C4Ca6k3/OnkxnKwnogcsmdgs2l8liT3qVHP04Oc7Uymq2v09bGb6nPu +fOrkXS9F6mSClxHG/q59AGOWsXK1xzIRV1eu8W2SNdyeFVU1JHiQe444xLoPul5t +InWasKayFsPlJfWNc8EoU8COjNhfo/GovFTHVjh9oUR/gwEFVwifIHihRE0Hazn2 +EQSLaOr2LM0TsRsQroFjmwSGgI+X2bfbMTqWOQIDAQABAoIBAFWZwDTeESBdrLcT +zHZe++cJLxE4AObn2LrWANEv5AeySYsyzjRBYObIN9IzrgTb8uJ900N/zVr5VkxH +xUa5PKbOcowd2NMfBTw5EEnaNbILLm+coHdanrNzVu59I9TFpAFoPavrNt/e2hNo +NMGPSdOkFi81LLl4xoadz/WR6O/7N2famM+0u7C2uBe+TrVwHyuqboYoidJDhO8M +w4WlY9QgAUhkPyzZqrl+VfF1aDTGVf4LJgaVevfFCas8Ws6DQX5q4QdIoV6/0vXi +B1M+aTnWjHuiIzjBMWhcYW2+I5zfwNWRXaxdlrYXRukGSdnyO+DH/FhHePJgmlkj +NInADDkCgYEA6MEQFOFSCc/ELXYWgStsrtIlJUcsLdLBsy1ocyQa2lkVUw58TouW +RciE6TjW9rp31pfQUnO2l6zOUC6LT9Jvlb9PSsyW+rvjtKB5PjJI6W0hjX41wEO6 +fshFELMJd9W+Ezao2AsP2hZJ8McCF8no9e00+G4xTAyxHsNI2AFTCQcCgYEA5cWZ +JwNb4t7YeEajPt9xuYNUOQpjvQn1aGOV7KcwTx5ELP/Hzi723BxHs7GSdrLkkDmi +Gpb+mfL4wxCt0fK0i8GFQsRn5eusyq9hLqP/bmjpHoXe/1uajFbE1fZQR+2LX05N +3ATlKaH2hdfCJedFa4wf43+cl6Yhp6ZA0Yet1r8CgYEAwiu1j8W9G+RRA5/8/DtO +yrUTOfsbFws4fpLGDTA0mq0whf6Soy/96C90+d9qLaC3srUpnG9eB0CpSOjbXXbv +kdxseLkexwOR3bD2FHX8r4dUM2bzznZyEaxfOaQypN8SV5ME3l60Fbr8ajqLO288 +wlTmGM5Mn+YCqOg/T7wjGmcCgYBpzNfdl/VafOROVbBbhgXWtzsz3K3aYNiIjbp+ +MunStIwN8GUvcn6nEbqOaoiXcX4/TtpuxfJMLw4OvAJdtxUdeSmEee2heCijV6g3 +ErrOOy6EqH3rNWHvlxChuP50cFQJuYOueO6QggyCyruSOnDDuc0BM0SGq6+5g5s7 +H++S/wKBgQDIkqBtFr9UEf8d6JpkxS0RXDlhSMjkXmkQeKGFzdoJcYVFIwq8jTNB +nJrVIGs3GcBkqGic+i7rTO1YPkquv4dUuiIn+vKZVoO6b54f+oPBXd4S0BnuEqFE +rdKNuCZhiaE2XD9L/O9KP1fh5bfEcKwazQ23EvpJHBMm8BGC+/YZNw== +-----END RSA PRIVATE KEY-----`)) + k, _ := x509.ParsePKCS1PrivateKey(b.Bytes) + return k +}() + +var cert = func() *x509.Certificate { + b, _ := pem.Decode([]byte(`-----BEGIN CERTIFICATE----- +MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV +BAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5 +NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8A +hs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+a +ucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWx +m+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6 +D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURN +B2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0O +BBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56 +zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5 +pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uv +NONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEf +y/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL +/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsb +GFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTL +UzreO96WzlBBMtY= +-----END CERTIFICATE-----`)) + c, _ := x509.ParseCertificate(b.Bytes) + return c +}() + +// Example from https://github.com/crewjam/saml/blob/main/example/idp/idp.go +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + schema := os.Getenv("SCHEMA") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + baseURL, err := url.Parse(schema + "://" + host + ":" + port) + if err != nil { + + panic(err) + } + + idpServer, err := samlidp.New(samlidp.Options{ + URL: *baseURL, + Logger: logger.DefaultLogger, + Key: key, + Certificate: cert, + Store: &samlidp.MemoryStore{}, + }) + if err != nil { + + panic(err) + } + + metadata, err := xml.MarshalIndent(idpServer.IDP.Metadata(), "", " ") + if err != nil { + panic(err) + } + idpID, err := createZitadelResources(apiURL, pat, domain, metadata) + if err != nil { + panic(err) + } + + lis := bind.Socket(":" + baseURL.Port()) + goji.Handle("/*", idpServer) + + go func() { + goji.ServeListener(lis) + }() + + addService(idpServer, apiURL+"/idps/"+idpID+"/saml/metadata") + addUsers(idpServer) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + if err := lis.Close(); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func addService(idpServer *samlidp.Server, spURLStr string) { + metadataResp, err := http.Get(spURLStr) + if err != nil { + panic(err) + } + defer metadataResp.Body.Close() + + idpServer.HandlePutService( + web.C{URLParams: map[string]string{"id": spURLStr}}, + httptest.NewRecorder(), + httptest.NewRequest(http.MethodPost, spURLStr, metadataResp.Body), + ) +} + +func getSPMetadata(r io.Reader) (spMetadata *saml.EntityDescriptor, err error) { + var data []byte + if data, err = io.ReadAll(r); err != nil { + return nil, err + } + + spMetadata = &saml.EntityDescriptor{} + if err := xrv.Validate(bytes.NewBuffer(data)); err != nil { + return nil, err + } + + if err := xml.Unmarshal(data, &spMetadata); err != nil { + if err.Error() == "expected element type but have " { + entities := &saml.EntitiesDescriptor{} + if err := xml.Unmarshal(data, &entities); err != nil { + return nil, err + } + + for _, e := range entities.EntityDescriptors { + if len(e.SPSSODescriptors) > 0 { + return &e, nil + } + } + + // there were no SPSSODescriptors in the response + return nil, errors.New("metadata contained no service provider metadata") + } + + return nil, err + } + + return spMetadata, nil +} + +func addUsers(idpServer *samlidp.Server) { + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.DefaultCost) + err := idpServer.Store.Put("/users/alice", samlidp.User{Name: "alice", + HashedPassword: hashedPassword, + Groups: []string{"Administrators", "Users"}, + Email: "alice@example.com", + CommonName: "Alice Smith", + Surname: "Smith", + GivenName: "Alice", + }) + if err != nil { + panic(err) + } + + err = idpServer.Store.Put("/users/bob", samlidp.User{ + Name: "bob", + HashedPassword: hashedPassword, + Groups: []string{"Users"}, + Email: "bob@example.com", + CommonName: "Bob Smith", + Surname: "Smith", + GivenName: "Bob", + }) + if err != nil { + panic(err) + } +} + +func createZitadelResources(apiURL, pat, domain string, metadata []byte) (string, error) { + idpID, err := CreateIDP(apiURL, pat, domain, metadata) + if err != nil { + return "", err + } + return idpID, ActivateIDP(apiURL, pat, domain, idpID) +} + +type createIDP struct { + Name string `json:"name"` + MetadataXml string `json:"metadataXml"` + Binding string `json:"binding"` + WithSignedRequest bool `json:"withSignedRequest"` + ProviderOptions providerOptions `json:"providerOptions"` + NameIdFormat string `json:"nameIdFormat"` +} +type providerOptions struct { + IsLinkingAllowed bool `json:"isLinkingAllowed"` + IsCreationAllowed bool `json:"isCreationAllowed"` + IsAutoCreation bool `json:"isAutoCreation"` + IsAutoUpdate bool `json:"isAutoUpdate"` + AutoLinking string `json:"autoLinking"` +} + +type idp struct { + ID string `json:"id"` +} + +func CreateIDP(apiURL, pat, domain string, idpMetadata []byte) (string, error) { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(idpMetadata))) + base64.URLEncoding.Encode(encoded, idpMetadata) + + createIDP := &createIDP{ + Name: "CREWJAM", + MetadataXml: string(encoded), + Binding: "SAML_BINDING_REDIRECT", + WithSignedRequest: false, + ProviderOptions: providerOptions{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: "AUTO_LINKING_OPTION_USERNAME", + }, + NameIdFormat: "SAML_NAME_ID_FORMAT_PERSISTENT", + } + + resp, err := doRequestWithHeaders(apiURL+"/admin/v1/idps/saml", pat, domain, createIDP) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + idp := new(idp) + if err := json.Unmarshal(data, idp); err != nil { + return "", err + } + return idp.ID, nil +} + +type activateIDP struct { + IdpId string `json:"idpId"` +} + +func ActivateIDP(apiURL, pat, domain string, idpID string) error { + activateIDP := &activateIDP{ + IdpId: idpID, + } + _, err := doRequestWithHeaders(apiURL+"/admin/v1/policies/login/idps", pat, domain, activateIDP) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/oidcrp/go.mod b/login/apps/login-test-acceptance/oidcrp/go.mod new file mode 100644 index 0000000000..f2cda3058e --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/go.mod @@ -0,0 +1,26 @@ +module github.com/zitadel/typescript/acceptance/oidc + +go 1.24.1 + +require ( + github.com/google/uuid v1.6.0 + github.com/sirupsen/logrus v1.9.3 + github.com/zitadel/logging v0.6.1 + github.com/zitadel/oidc/v3 v3.36.1 +) + +require ( + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/zitadel/schema v1.3.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/login/apps/login-test-acceptance/oidcrp/go.sum b/login/apps/login-test-acceptance/oidcrp/go.sum new file mode 100644 index 0000000000..33244ea6eb --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/go.sum @@ -0,0 +1,67 @@ +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y= +github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= +github.com/zitadel/oidc/v3 v3.36.1 h1:1AT1NqKKEqAwx4GmKJZ9fYkWH2WIn/VKMfQ46nBtRf0= +github.com/zitadel/oidc/v3 v3.36.1/go.mod h1:dApGZLvWZTHRuxmcbQlW5d2XVjVYR3vGOdq536igmTs= +github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= +github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/apps/login-test-acceptance/oidcrp/main.go b/login/apps/login-test-acceptance/oidcrp/main.go new file mode 100644 index 0000000000..72ae5f57e9 --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/main.go @@ -0,0 +1,322 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + + "github.com/zitadel/logging" + "github.com/zitadel/oidc/v3/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v3/pkg/http" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +var ( + callbackPath = "/auth/callback" + key = []byte("test1234test1234") +) + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + loginURL := os.Getenv("LOGIN_URL") + issuer := os.Getenv("ISSUER") + port := os.Getenv("PORT") + scopeList := strings.Split(os.Getenv("SCOPES"), " ") + + redirectURI := fmt.Sprintf("%s%s", issuer, callbackPath) + cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) + + clientID, clientSecret, err := createZitadelResources(apiURL, pat, domain, redirectURI, loginURL) + if err != nil { + panic(err) + } + + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }), + ) + client := &http.Client{ + Timeout: time.Minute, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + // enable outgoing request logging + logging.EnableHTTPClient(client, + logging.WithClientGroup("client"), + ) + + options := []rp.Option{ + rp.WithCookieHandler(cookieHandler), + rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)), + rp.WithHTTPClient(client), + rp.WithLogger(logger), + rp.WithSigningAlgsFromDiscovery(), + rp.WithCustomDiscoveryUrl(issuer + "/.well-known/openid-configuration"), + } + if clientSecret == "" { + options = append(options, rp.WithPKCE(cookieHandler)) + } + + // One can add a logger to the context, + // pre-defining log attributes as required. + ctx := logging.ToContext(context.TODO(), logger) + provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI, scopeList, options...) + if err != nil { + logrus.Fatalf("error creating provider %s", err.Error()) + } + + // generate some state (representing the state of the user in your application, + // e.g. the page where he was before sending him to login + state := func() string { + return uuid.New().String() + } + + urlOptions := []rp.URLParamOpt{ + rp.WithPromptURLParam("Welcome back!"), + } + + // register the AuthURLHandler at your preferred path. + // the AuthURLHandler creates the auth request and redirects the user to the auth server. + // including state handling with secure cookie and the possibility to use PKCE. + // Prompts can optionally be set to inform the server of + // any messages that need to be prompted back to the user. + http.Handle("/login", rp.AuthURLHandler( + state, + provider, + urlOptions..., + )) + + // for demonstration purposes the returned userinfo response is written as JSON object onto response + marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) { + fmt.Println("access token", tokens.AccessToken) + fmt.Println("refresh token", tokens.RefreshToken) + fmt.Println("id token", tokens.IDToken) + + data, err := json.Marshal(info) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("content-type", "application/json") + w.Write(data) + } + + // register the CodeExchangeHandler at the callbackPath + // the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function + // with the returned tokens from the token endpoint + // in this example the callback function itself is wrapped by the UserinfoCallback which + // will call the Userinfo endpoint, check the sub and pass the info into the callback function + http.Handle(callbackPath, rp.CodeExchangeHandler(rp.UserinfoCallback(marshalUserinfo), provider)) + + // if you would use the callback without calling the userinfo endpoint, simply switch the callback handler for: + // + // http.Handle(callbackPath, rp.CodeExchangeHandler(marshalToken, provider)) + + // simple counter for request IDs + var counter atomic.Int64 + // enable incomming request logging + mw := logging.Middleware( + logging.WithLogger(logger), + logging.WithGroup("server"), + logging.WithIDFunc(func() slog.Attr { + return slog.Int64("id", counter.Add(1)) + }), + ) + + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + + server := &http.Server{ + Addr: ":" + port, + Handler: mw(http.DefaultServeMux), + } + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain, redirectURI, loginURL string) (string, string, error) { + projectID, err := CreateProject(apiURL, pat, domain) + if err != nil { + return "", "", err + } + return CreateApp(apiURL, pat, domain, projectID, redirectURI, loginURL) +} + +type project struct { + ID string `json:"id"` +} +type createProject struct { + Name string `json:"name"` + ProjectRoleAssertion bool `json:"projectRoleAssertion"` + ProjectRoleCheck bool `json:"projectRoleCheck"` + HasProjectCheck bool `json:"hasProjectCheck"` + PrivateLabelingSetting string `json:"privateLabelingSetting"` +} + +func CreateProject(apiURL, pat, domain string) (string, error) { + createProject := &createProject{ + Name: "OIDC", + ProjectRoleAssertion: false, + ProjectRoleCheck: false, + HasProjectCheck: false, + PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED", + } + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + p := new(project) + if err := json.Unmarshal(data, p); err != nil { + return "", err + } + fmt.Printf("projectID: %+v\n", p.ID) + return p.ID, nil +} + +type createApp struct { + Name string `json:"name"` + RedirectUris []string `json:"redirectUris"` + ResponseTypes []string `json:"responseTypes"` + GrantTypes []string `json:"grantTypes"` + AppType string `json:"appType"` + AuthMethodType string `json:"authMethodType"` + PostLogoutRedirectUris []string `json:"postLogoutRedirectUris"` + Version string `json:"version"` + DevMode bool `json:"devMode"` + AccessTokenType string `json:"accessTokenType"` + AccessTokenRoleAssertion bool `json:"accessTokenRoleAssertion"` + IdTokenRoleAssertion bool `json:"idTokenRoleAssertion"` + IdTokenUserinfoAssertion bool `json:"idTokenUserinfoAssertion"` + ClockSkew string `json:"clockSkew"` + AdditionalOrigins []string `json:"additionalOrigins"` + SkipNativeAppSuccessPage bool `json:"skipNativeAppSuccessPage"` + BackChannelLogoutUri []string `json:"backChannelLogoutUri"` + LoginVersion version `json:"loginVersion"` +} + +type version struct { + LoginV2 loginV2 `json:"loginV2"` +} +type loginV2 struct { + BaseUri string `json:"baseUri"` +} + +type app struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + +func CreateApp(apiURL, pat, domain, projectID string, redirectURI, loginURL string) (string, string, error) { + createApp := &createApp{ + Name: "OIDC", + RedirectUris: []string{redirectURI}, + ResponseTypes: []string{"OIDC_RESPONSE_TYPE_CODE"}, + GrantTypes: []string{"OIDC_GRANT_TYPE_AUTHORIZATION_CODE"}, + AppType: "OIDC_APP_TYPE_WEB", + AuthMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", + Version: "OIDC_VERSION_1_0", + DevMode: true, + AccessTokenType: "OIDC_TOKEN_TYPE_BEARER", + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: "1s", + SkipNativeAppSuccessPage: true, + LoginVersion: version{ + LoginV2: loginV2{ + BaseUri: loginURL, + }, + }, + } + + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/oidc", pat, domain, createApp) + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + a := new(app) + if err := json.Unmarshal(data, a); err != nil { + return "", "", err + } + return a.ClientID, a.ClientSecret, err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/package.json b/login/apps/login-test-acceptance/package.json new file mode 100644 index 0000000000..1fb83f0345 --- /dev/null +++ b/login/apps/login-test-acceptance/package.json @@ -0,0 +1,18 @@ +{ + "name": "login-test-acceptance", + "private": true, + "scripts": { + "test:acceptance": "dotenv -e ../login/.env.test.local pnpm exec playwright", + "test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test pnpm exec turbo run test:acceptance:setup:dev", + "test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev" + }, + "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", + "gaxios": "^7.1.0", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login-test-acceptance/pat/.gitignore b/login/apps/login-test-acceptance/pat/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/pat/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/pat/.gitkeep b/login/apps/login-test-acceptance/pat/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/playwright-report/.gitignore b/login/apps/login-test-acceptance/playwright-report/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/playwright-report/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/playwright-report/.gitkeep b/login/apps/login-test-acceptance/playwright-report/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/playwright.config.ts b/login/apps/login-test-acceptance/playwright.config.ts new file mode 100644 index 0000000000..8025db3238 --- /dev/null +++ b/login/apps/login-test-acceptance/playwright.config.ts @@ -0,0 +1,78 @@ +import { defineConfig, devices } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; + +dotenv.config({ path: path.resolve(__dirname, "../login/.env.test.local") }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + expect: { + timeout: 10_000, // 10 seconds + }, + timeout: 300 * 1000, // 5 minutes + globalTimeout: 30 * 60_000, // 30 minutes + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ["line"], + ["html", { open: process.env.CI ? "never" : "on-failure", host: "0.0.0.0", outputFolder: "./playwright-report/html" }], + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.LOGIN_BASE_URL || "http://127.0.0.1:3000", + trace: "retain-on-failure", + headless: true, + screenshot: "only-on-failure", + video: "retain-on-failure", + ignoreHTTPSErrors: true, + }, + outputDir: "test-results/results", + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + /* + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + TODO: webkit fails. Is this a bug? + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + */ + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], +}); diff --git a/login/apps/login-test-acceptance/samlsp/go.mod b/login/apps/login-test-acceptance/samlsp/go.mod new file mode 100644 index 0000000000..9986149bfb --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/go.mod @@ -0,0 +1,18 @@ +module github.com/zitadel/typescript/acceptance/saml + +go 1.24.0 + +require github.com/crewjam/saml v0.4.14 + +require ( + github.com/beevik/etree v1.5.0 // indirect + github.com/crewjam/httperr v0.2.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/russellhaering/goxmldsig v1.5.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + golang.org/x/crypto v0.36.0 // indirect +) diff --git a/login/apps/login-test-acceptance/samlsp/go.sum b/login/apps/login-test-acceptance/samlsp/go.sum new file mode 100644 index 0000000000..3394a39410 --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/go.sum @@ -0,0 +1,38 @@ +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= +github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo= +github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw= +github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/login/apps/login-test-acceptance/samlsp/main.go b/login/apps/login-test-acceptance/samlsp/main.go new file mode 100644 index 0000000000..9dcfd13796 --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/main.go @@ -0,0 +1,271 @@ +package main + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/crewjam/saml/samlsp" +) + +var keyPair = func() tls.Certificate { + cert := []byte(`-----BEGIN CERTIFICATE----- +MIIDITCCAgmgAwIBAgIUKjAUmxsHO44X+/TKBNciPgNl1GEwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1wbGUuY29tMB4XDTI0MTIxOTEz +Mzc1MVoXDTI1MTIxOTEzMzc1MVowIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1w +bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0QYuJsayILRI +hVT7G1DlitVSXnt1iw3gEXJZfe81Egz06fUbvXF6Yo1LJmwYpqe/rm+hf4FNUb8e +2O+LH2FieA9FkVe4P2gKOzw87A/KxvpV8stgNgl4LlqRCokbc1AzeE/NiLr5TcTD +RXm3DUcYxXxinprtDu2jftFysaOZmNAukvE/iL6qS3X6ggVEDDM7tY9n5FV2eJ4E +p0ImKfypi2aZYROxOK+v5x9ryFRMl4y07lMDvmtcV45uXYmfGNCgG9PNf91Kk/mh +JxEQbxycJwFoSi9XWljR8ahPdO11LXG7Dsj/RVbY8k2LdKNstl6Ae3aCpbe9u2Pj +vxYs1bVJuQIDAQABo1MwUTAdBgNVHQ4EFgQU+mRVN5HYJWgnpopReaLhf2cMcoYw +HwYDVR0jBBgwFoAU+mRVN5HYJWgnpopReaLhf2cMcoYwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEABJpHVuc9tGhD04infRVlofvqXIUizTlOrjZX +vozW9pIhSWEHX8o+sJP8AMZLnrsdq+bm0HE0HvgYrw7Lb8pd4FpR46TkFHjeukoj +izqfgckjIBl2nwPGlynbKA0/U/rTCSxVt7XiAn+lgYUGIpOzNdk06/hRMitrMNB7 +t2C97NseVC4b1ZgyFrozsefCfUmD8IJF0+XJ4Wzmsh0jRrI8koCtVmPYnKn6vw1b +cZprg/97CWHYrsavd406wOB60CMtYl83Q16ucOF1dretDFqJC5kY+aFLvuqfag2+ +kIaoPV1MnGsxveQyyHdOsEatS5XOv/1OWcmnvePDPxcvb9jCcw== +-----END CERTIFICATE----- +`) + key := []byte(`-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRBi4mxrIgtEiF +VPsbUOWK1VJee3WLDeARcll97zUSDPTp9Ru9cXpijUsmbBimp7+ub6F/gU1Rvx7Y +74sfYWJ4D0WRV7g/aAo7PDzsD8rG+lXyy2A2CXguWpEKiRtzUDN4T82IuvlNxMNF +ebcNRxjFfGKemu0O7aN+0XKxo5mY0C6S8T+IvqpLdfqCBUQMMzu1j2fkVXZ4ngSn +QiYp/KmLZplhE7E4r6/nH2vIVEyXjLTuUwO+a1xXjm5diZ8Y0KAb081/3UqT+aEn +ERBvHJwnAWhKL1daWNHxqE907XUtcbsOyP9FVtjyTYt0o2y2XoB7doKlt727Y+O/ +FizVtUm5AgMBAAECggEACak+l5f6Onj+u5vrjc4JyAaXW6ra6loSM9g8Uu3sHukW +plwoA7Pzp0u20CAxrP1Gpqw984/hSCCcb0Q2ItWMWLaC/YZni5W2WFnOyo3pzlPa +hmH4UNMT+ReCSfF/oW8w69QLcNEMjhfEu0i2iWBygIlA4SoRwC2Db6yEX7nLMwUB +6AICid9hfeACNRz/nq5ytdcHdmcB7Ptgb9jLiXr6RZw26g5AsRPHU3LdcyZAOXjP +aUHriHuHQFKAVkoEUxslvCB6ePCTCpB0bSAuzQbeGoY8fmvmNSCvJ1vrH5hiSUYp +Axtl5iNgFl5o9obb0eBYlY9x3pMSz0twdbCwfR7HAQKBgQDtWhmFm0NaJALoY+tq +lIIC0EOMSrcRIlgeXr6+g8womuDOMi5m/Nr5Mqt4mPOdP4HytrQb+a/ZmEm17KHh +mQb1vwH8ffirCBHbPNC1vwSNoxDKv9E6OysWlKiOzxPFSVZr3dKl2EMX6qi17n0l +LBrGXXaNPgYiHSmwBA5CZvvouQKBgQDhclGJfZfuoubQkUuz8yOA2uxalh/iUmQ/ +G8ac6/w7dmnL9pXehqCWh06SeC3ZvW7yrf7IIGx4sTJji2FzQ+8Ta6pPELMyBEXr +1VirIFrlNVMlMQEbZcbzdzEhchM1RUpZJtl3b4amvH21UcRB69d9klcDRisKoFRm +k0P9QLHpAQKBgQDh5J9nphZa4u0ViYtTW1XFIbs3+R/0IbCl7tww67TRbF3KQL4i +7EHna88ALumkXf3qJvKRsXgoaqS0jSqgUAjst8ZHLQkOldaQxneIkezedDSWEisp +9YgTrJYjnHefiyXB8VL63jE0wPOiewEF8Mzmv6sFz+L8cq7rQ2Di16qmmQKBgQDH +bvCwVxkrMpJK2O2GH8U9fOzu6bUE6eviY/jb4mp8U7EdjGJhuuieoM2iBoxQ/SID +rmYftYcfcWlo4+juJZ99p5W+YcCTs3IDQPUyVOnzr6uA0Avxp6RKxhsBQj+5tTUj +Dpn77P3JzB7MYqvhwPcdD3LH46+5s8FWCFpx02RPAQKBgARbngtggfifatcsMC7n +lSv/FVLH7LYQAHdoW/EH5Be7FeeP+eQvGXwh1dgl+u0VZO8FvI8RwFganpBRR2Nc +ZSBRIb0fSUlTvIsckSWjpEvUJUomJXyi4PIZAfNvd9/u1uLInQiCDtObwb6hnLTU +FHHEZ+dR4eMaJp6PhNm8hu2O +-----END PRIVATE KEY----- +`) + + kp, err := tls.X509KeyPair(cert, key) + if err != nil { + panic(err) + } + kp.Leaf, err = x509.ParseCertificate(kp.Certificate[0]) + if err != nil { + panic(err) + } + return kp +}() + +func hello(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %s!", samlsp.AttributeFromContext(r.Context(), "UserName")) +} + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + loginURL := os.Getenv("LOGIN_URL") + idpURL := os.Getenv("IDP_URL") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + idpMetadataURL, err := url.Parse(idpURL) + if err != nil { + panic(err) + } + idpMetadata, err := samlsp.FetchMetadata(context.Background(), http.DefaultClient, + *idpMetadataURL) + if err != nil { + panic(fmt.Errorf("failed to fetch IDP metadata from %s: %w", idpURL, err)) + } + fmt.Printf("idpMetadata: %+v\n", idpMetadata) + rootURL, err := url.Parse(host + ":" + port) + if err != nil { + panic(err) + } + + samlSP, err := samlsp.New(samlsp.Options{ + URL: *rootURL, + Key: keyPair.PrivateKey.(*rsa.PrivateKey), + Certificate: keyPair.Leaf, + IDPMetadata: idpMetadata, + }) + if err != nil { + panic(err) + } + + server := &http.Server{ + Addr: ":" + port, + } + app := http.HandlerFunc(hello) + http.Handle("/hello", samlSP.RequireAccount(app)) + http.Handle("/saml/", samlSP) + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + metadata, err := xml.MarshalIndent(samlSP.ServiceProvider.Metadata(), "", " ") + if err != nil { + panic(err) + } + if err := createZitadelResources(apiURL, pat, domain, metadata, loginURL); err != nil { + panic(err) + } + + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain string, metadata []byte, loginURL string) error { + projectID, err := CreateProject(apiURL, pat, domain) + if err != nil { + return err + } + return CreateApp(apiURL, pat, domain, projectID, metadata, loginURL) +} + +type project struct { + ID string `json:"id"` +} +type createProject struct { + Name string `json:"name"` + ProjectRoleAssertion bool `json:"projectRoleAssertion"` + ProjectRoleCheck bool `json:"projectRoleCheck"` + HasProjectCheck bool `json:"hasProjectCheck"` + PrivateLabelingSetting string `json:"privateLabelingSetting"` +} + +func CreateProject(apiURL, pat, domain string) (string, error) { + createProject := &createProject{ + Name: "SAML", + ProjectRoleAssertion: false, + ProjectRoleCheck: false, + HasProjectCheck: false, + PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED", + } + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + p := new(project) + if err := json.Unmarshal(data, p); err != nil { + return "", err + } + return p.ID, nil +} + +type createApp struct { + Name string `json:"name"` + MetadataXml string `json:"metadataXml"` + LoginVersion version `json:"loginVersion"` +} +type version struct { + LoginV2 loginV2 `json:"loginV2"` +} +type loginV2 struct { + BaseUri string `json:"baseUri"` +} + +func CreateApp(apiURL, pat, domain, projectID string, spMetadata []byte, loginURL string) error { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(spMetadata))) + base64.URLEncoding.Encode(encoded, spMetadata) + + createApp := &createApp{ + Name: "SAML", + MetadataXml: string(encoded), + LoginVersion: version{ + LoginV2: loginV2{ + BaseUri: loginURL, + }, + }, + } + _, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/saml", pat, domain, createApp) + if err != nil { + return fmt.Errorf("error creating saml app with request %+v: %v", *createApp, err) + } + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/setup/go.mod b/login/apps/login-test-acceptance/setup/go.mod new file mode 100644 index 0000000000..7be166ef9b --- /dev/null +++ b/login/apps/login-test-acceptance/setup/go.mod @@ -0,0 +1,3 @@ +module github.com/zitadel/typescript/apps/login-test-acceptance/setup + +go 1.23.3 diff --git a/login/apps/login-test-acceptance/setup/go.sum b/login/apps/login-test-acceptance/setup/go.sum new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/setup/main.go b/login/apps/login-test-acceptance/setup/main.go new file mode 100644 index 0000000000..38dd16da61 --- /dev/null +++ b/login/apps/login-test-acceptance/setup/main.go @@ -0,0 +1,3 @@ +package main + +func main() {} diff --git a/login/apps/login-test-acceptance/setup/setup.sh b/login/apps/login-test-acceptance/setup/setup.sh new file mode 100755 index 0000000000..9d1a04e18f --- /dev/null +++ b/login/apps/login-test-acceptance/setup/setup.sh @@ -0,0 +1,139 @@ +#!/bin/sh + +set -e pipefail + +PAT_FILE=${PAT_FILE:-./pat/zitadel-admin-sa.pat} +LOGIN_BASE_URL=${LOGIN_BASE_URL:-"http://localhost:3000"} +ZITADEL_API_PROTOCOL="${ZITADEL_API_PROTOCOL:-http}" +ZITADEL_API_DOMAIN="${ZITADEL_API_DOMAIN:-localhost}" +ZITADEL_API_PORT="${ZITADEL_API_PORT:-8080}" +ZITADEL_API_URL="${ZITADEL_API_URL:-${ZITADEL_API_PROTOCOL}://${ZITADEL_API_DOMAIN}:${ZITADEL_API_PORT}}" +ZITADEL_API_INTERNAL_URL="${ZITADEL_API_INTERNAL_URL:-${ZITADEL_API_URL}}" +SINK_EMAIL_INTERNAL_URL="${SINK_EMAIL_INTERNAL_URL:-"http://sink:3333/email"}" +SINK_SMS_INTERNAL_URL="${SINK_SMS_INTERNAL_URL:-"http://sink:3333/sms"}" +SINK_NOTIFICATION_URL="${SINK_NOTIFICATION_URL:-"http://localhost:3333/notification"}" +WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.test.local} + +if [ -z "${PAT}" ]; then + echo "Reading PAT from file ${PAT_FILE}" + PAT=$(cat ${PAT_FILE}) +fi + +################################################################# +# ServiceAccount as Login Client +################################################################# + +SERVICEACCOUNT_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/management/v1/users/machine" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"userName\": \"login\", \"name\": \"Login v2\", \"description\": \"Serviceaccount for Login v2\", \"accessTokenType\": \"ACCESS_TOKEN_TYPE_BEARER\"}") +echo "Received ServiceAccount response: ${SERVICEACCOUNT_RESPONSE}" + +SERVICEACCOUNT_ID=$(echo ${SERVICEACCOUNT_RESPONSE} | jq -r '. | .userId') +echo "Received ServiceAccount ID: ${SERVICEACCOUNT_ID}" + +MEMBER_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/members" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"userId\": \"${SERVICEACCOUNT_ID}\", \"roles\": [\"IAM_LOGIN_CLIENT\"]}") +echo "Received Member response: ${MEMBER_RESPONSE}" + +SA_PAT_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/management/v1/users/${SERVICEACCOUNT_ID}/pats" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"expirationDate\": \"2519-04-01T08:45:00.000000Z\"}") +echo "Received Member response: ${MEMBER_RESPONSE}" + +SA_PAT=$(echo ${SA_PAT_RESPONSE} | jq -r '. | .token') +echo "Received ServiceAccount Token: ${SA_PAT}" + +################################################################# +# Environment files +################################################################# + +echo "Writing environment file ${WRITE_ENVIRONMENT_FILE}." + +echo "ZITADEL_API_URL=${ZITADEL_API_URL} +ZITADEL_SERVICE_USER_TOKEN=${SA_PAT} +ZITADEL_ADMIN_TOKEN=${PAT} +SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL} +EMAIL_VERIFICATION=true +DEBUG=false +LOGIN_BASE_URL=${LOGIN_BASE_URL} +NODE_TLS_REJECT_UNAUTHORIZED=0 +ZITADEL_ADMIN_USER=${ZITADEL_ADMIN_USER:-"zitadel-admin@zitadel.localhost"} +NEXT_PUBLIC_BASE_PATH=/ui/v2/login +" > ${WRITE_ENVIRONMENT_FILE} + +echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}" +cat ${WRITE_ENVIRONMENT_FILE} + +################################################################# +# SMS provider with HTTP +################################################################# + +SMSHTTP_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/http" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"endpoint\": \"${SINK_SMS_INTERNAL_URL}\", \"description\": \"test\"}") +echo "Received SMS HTTP response: ${SMSHTTP_RESPONSE}" + +SMSHTTP_ID=$(echo ${SMSHTTP_RESPONSE} | jq -r '. | .id') +echo "Received SMS HTTP ID: ${SMSHTTP_ID}" + +SMS_ACTIVE_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/${SMSHTTP_ID}/_activate" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json") +echo "Received SMS active response: ${SMS_ACTIVE_RESPONSE}" + +################################################################# +# Email provider with HTTP +################################################################# + +EMAILHTTP_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/http" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"endpoint\": \"${SINK_EMAIL_INTERNAL_URL}\", \"description\": \"test\"}") +echo "Received Email HTTP response: ${EMAILHTTP_RESPONSE}" + +EMAILHTTP_ID=$(echo ${EMAILHTTP_RESPONSE} | jq -r '. | .id') +echo "Received Email HTTP ID: ${EMAILHTTP_ID}" + +EMAIL_ACTIVE_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/${EMAILHTTP_ID}/_activate" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json") +echo "Received Email active response: ${EMAIL_ACTIVE_RESPONSE}" + +################################################################# +# Wait for projection of default organization in ZITADEL +################################################################# + +DEFAULTORG_RESPONSE_RESULTS=0 +# waiting for default organization +until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ] +do + DEFAULTORG_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/v2/organizations/_search" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"queries\": [{\"defaultQuery\":{}}]}" ) + echo "Received default organization response: ${DEFAULTORG_RESPONSE}" + DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length') + echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}" +done + diff --git a/login/apps/login-test-acceptance/sink/go.mod b/login/apps/login-test-acceptance/sink/go.mod new file mode 100644 index 0000000000..1da7622b58 --- /dev/null +++ b/login/apps/login-test-acceptance/sink/go.mod @@ -0,0 +1,3 @@ +module github.com/zitadel/typescript/acceptance/sink + +go 1.24.0 diff --git a/login/apps/login-test-acceptance/sink/go.sum b/login/apps/login-test-acceptance/sink/go.sum new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/sink/main.go b/login/apps/login-test-acceptance/sink/main.go new file mode 100644 index 0000000000..f3795ba0d0 --- /dev/null +++ b/login/apps/login-test-acceptance/sink/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" +) + +type serializableData struct { + ContextInfo map[string]interface{} `json:"contextInfo,omitempty"` + Args map[string]interface{} `json:"args,omitempty"` +} + +type response struct { + Recipient string `json:"recipient,omitempty"` +} + +func main() { + port := flag.String("port", "3333", "used port for the sink") + email := flag.String("email", "/email", "path for a sent email") + emailKey := flag.String("email-key", "recipientEmailAddress", "value in the sent context info of the email used as key to retrieve the notification") + sms := flag.String("sms", "/sms", "path for a sent sms") + smsKey := flag.String("sms-key", "recipientPhoneNumber", "value in the sent context info of the sms used as key to retrieve the notification") + notification := flag.String("notification", "/notification", "path to receive the notification") + flag.Parse() + + messages := make(map[string]serializableData) + + http.HandleFunc(*email, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData := serializableData{} + if err := json.Unmarshal(data, &serializableData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + email, ok := serializableData.ContextInfo[*emailKey].(string) + if !ok { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fmt.Println(email + ": " + string(data)) + messages[email] = serializableData + io.WriteString(w, "Email!\n") + }) + + http.HandleFunc(*sms, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData := serializableData{} + if err := json.Unmarshal(data, &serializableData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + phone, ok := serializableData.ContextInfo[*smsKey].(string) + if !ok { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fmt.Println(phone + ": " + string(data)) + messages[phone] = serializableData + io.WriteString(w, "SMS!\n") + }) + + http.HandleFunc(*notification, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + response := response{} + if err := json.Unmarshal(data, &response); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + msg, ok := messages[response.Recipient] + if !ok { + http.Error(w, "No messages found for recipient: "+response.Recipient, http.StatusNotFound) + return + } + serializableData, err := json.Marshal(msg) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, string(serializableData)) + }) + + fmt.Println("Starting server on", *port) + fmt.Println(*email, " for email handling") + fmt.Println(*sms, " for sms handling") + fmt.Println(*notification, " for retrieving notifications") + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + err := http.ListenAndServe(":"+*port, nil) + if err != nil { + panic("Server could not be started: " + err.Error()) + } +} diff --git a/login/apps/login-test-acceptance/test-results/.gitignore b/login/apps/login-test-acceptance/test-results/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/test-results/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/test-results/.gitkeep b/login/apps/login-test-acceptance/test-results/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/tests/admin.spec.ts b/login/apps/login-test-acceptance/tests/admin.spec.ts new file mode 100644 index 0000000000..13b748fc63 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/admin.spec.ts @@ -0,0 +1,7 @@ +import { test } from "@playwright/test"; +import { loginScreenExpect, loginWithPassword } from "./login"; + +test("admin login", async ({ page }) => { + await loginWithPassword(page, process.env["ZITADEL_ADMIN_USER"], "Password1!"); + await loginScreenExpect(page, "ZITADEL Admin"); +}); diff --git a/login/apps/login-test-acceptance/tests/code-screen.ts b/login/apps/login-test-acceptance/tests/code-screen.ts new file mode 100644 index 0000000000..3ab9dad26d --- /dev/null +++ b/login/apps/login-test-acceptance/tests/code-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function codeScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function codeScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify OTP code"); +} diff --git a/login/apps/login-test-acceptance/tests/code.ts b/login/apps/login-test-acceptance/tests/code.ts new file mode 100644 index 0000000000..e27d1f6150 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/code.ts @@ -0,0 +1,17 @@ +import { Page } from "@playwright/test"; +import { codeScreen } from "./code-screen"; +import { getOtpFromSink } from "./sink"; + +export async function otpFromSink(page: Page, key: string) { + const c = await getOtpFromSink(key); + await code(page, c); +} + +export async function code(page: Page, code: string) { + await codeScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function codeResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/email-verify-screen.ts b/login/apps/login-test-acceptance/tests/email-verify-screen.ts new file mode 100644 index 0000000000..b077ecb424 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function emailVerifyScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function emailVerifyScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify email"); +} diff --git a/login/apps/login-test-acceptance/tests/email-verify.spec.ts b/login/apps/login-test-acceptance/tests/email-verify.spec.ts new file mode 100644 index 0000000000..2c546b8eee --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify.spec.ts @@ -0,0 +1,69 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { emailVerify, emailVerifyResend } from "./email-verify"; +import { emailVerifyScreenExpect } from "./email-verify-screen"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { getCodeFromSink } from "./sink"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: false, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("user email not verified, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + const c = await getCodeFromSink(user.getUsername()); + await emailVerify(page, c); + // wait for resend of the code + await page.waitForTimeout(2000); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + await emailVerifyResend(page); + const c = await getCodeFromSink(user.getUsername()); + // wait for resend of the code + await page.waitForTimeout(2000); + await emailVerify(page, c); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, old code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + const c = await getCodeFromSink(user.getUsername()); + await emailVerifyResend(page); + // wait for resend of the code + await page.waitForTimeout(2000); + await emailVerify(page, c); + await emailVerifyScreenExpect(page, c); +}); + +test("user email not verified, wrong code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + const code = "wrong"; + await emailVerify(page, code); + await emailVerifyScreenExpect(page, code); +}); diff --git a/login/apps/login-test-acceptance/tests/email-verify.ts b/login/apps/login-test-acceptance/tests/email-verify.ts new file mode 100644 index 0000000000..5275e82bfe --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify.ts @@ -0,0 +1,15 @@ +import { Page } from "@playwright/test"; +import { emailVerifyScreen } from "./email-verify-screen"; + +export async function startEmailVerify(page: Page, loginname: string) { + await page.goto("./verify"); +} + +export async function emailVerify(page: Page, code: string) { + await emailVerifyScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function emailVerifyResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/idp-apple.spec.ts b/login/apps/login-test-acceptance/tests/idp-apple.spec.ts new file mode 100644 index 0000000000..32d3adba6b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-apple.spec.ts @@ -0,0 +1,102 @@ +// Note for all tests, in case Apple doesn't deliver all relevant information per default +// We should add an action in the needed cases + +import test from "@playwright/test"; + +test("login with Apple IDP", async ({ page }) => { + test.skip(); + // Given an Apple IDP is configured on the organization + // Given the user has an Apple added as auth method + // User authenticates with Apple + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Apple IDP - error", async ({ page }) => { + test.skip(); + // Given an Apple IDP is configured on the organization + // Given the user has an Apple added as auth method + // User is redirected to Apple + // User authenticates with Apple and gets an error + // User is redirect back to login + // An error is shown to the user "Something went wrong in Apple Login" +}); + +test("login with Apple IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Apple IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Apple IDP, no user linked, user link successful", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts new file mode 100644 index 0000000000..d68475a226 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Generic JWT IDP", async ({ page }) => { + test.skip(); + // Given a Generic JWT IDP is configured on the organization + // Given the user has Generic JWT IDP added as auth method + // User authenticates with the Generic JWT IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic JWT IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic JWT IDP is configured on the organization + // Given the user has Generic JWT IDP added as auth method + // User is redirected to the Generic JWT IDP + // User authenticates with the Generic JWT IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic JWT IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic JWT IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic JWT IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts new file mode 100644 index 0000000000..24c25d0005 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Generic OAuth IDP", async ({ page }) => { + test.skip(); + // Given a Generic OAuth IDP is configured on the organization + // Given the user has Generic OAuth IDP added as auth method + // User authenticates with the Generic OAuth IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic OAuth IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic OAuth IDP is configured on the organization + // Given the user has Generic OAuth IDP added as auth method + // User is redirected to the Generic OAuth IDP + // User authenticates with the Generic OAuth IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic OAuth IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic OAuth IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic OAuth IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts new file mode 100644 index 0000000000..391481f99d --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts @@ -0,0 +1,101 @@ +// Note, we should use a provider such as Google to test this, where we know OIDC standard is properly implemented + +import test from "@playwright/test"; + +test("login with Generic OIDC IDP", async ({ page }) => { + test.skip(); + // Given a Generic OIDC IDP is configured on the organization + // Given the user has Generic OIDC IDP added as auth method + // User authenticates with the Generic OIDC IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic OIDC IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic OIDC IDP is configured on the organization + // Given the user has Generic OIDC IDP added as auth method + // User is redirected to the Generic OIDC IDP + // User authenticates with the Generic OIDC IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic OIDC IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic OIDC IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic OIDC IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts b/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts new file mode 100644 index 0000000000..2c39092851 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitHub Enterprise IDP", async ({ page }) => { + test.skip(); + // Given a GitHub Enterprise IDP is configured on the organization + // Given the user has GitHub Enterprise IDP added as auth method + // User authenticates with the GitHub Enterprise IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitHub Enterprise IDP - error", async ({ page }) => { + test.skip(); + // Given the GitHub Enterprise IDP is configured on the organization + // Given the user has GitHub Enterprise IDP added as auth method + // User is redirected to the GitHub Enterprise IDP + // User authenticates with the GitHub Enterprise IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with GitHub Enterprise IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with GitHub Enterprise IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-github.spec.ts b/login/apps/login-test-acceptance/tests/idp-github.spec.ts new file mode 100644 index 0000000000..689e040537 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-github.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitHub IDP", async ({ page }) => { + test.skip(); + // Given a GitHub IDP is configured on the organization + // Given the user has GitHub IDP added as auth method + // User authenticates with the GitHub IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitHub IDP - error", async ({ page }) => { + test.skip(); + // Given the GitHub IDP is configured on the organization + // Given the user has GitHub IDP added as auth method + // User is redirected to the GitHub IDP + // User authenticates with the GitHub IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with GitHub IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with GitHub IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with GitHub IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts b/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts new file mode 100644 index 0000000000..1b05d5e19b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitLab Self-Hosted IDP", async ({ page }) => { + test.skip(); + // Given a GitLab Self-Hosted IDP is configured on the organization + // Given the user has GitLab Self-Hosted IDP added as auth method + // User authenticates with the GitLab Self-Hosted IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitLab Self-Hosted IDP - error", async ({ page }) => { + test.skip(); + // Given the GitLab Self-Hosted IDP is configured on the organization + // Given the user has GitLab Self-Hosted IDP added as auth method + // User is redirected to the GitLab Self-Hosted IDP + // User authenticates with the GitLab Self-Hosted IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Gitlab Self-Hosted IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Gitlab Self-Hosted IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts b/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts new file mode 100644 index 0000000000..fdb235843b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitLab IDP", async ({ page }) => { + test.skip(); + // Given a GitLab IDP is configured on the organization + // Given the user has GitLab IDP added as auth method + // User authenticates with the GitLab IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitLab IDP - error", async ({ page }) => { + test.skip(); + // Given the GitLab IDP is configured on the organization + // Given the user has GitLab IDP added as auth method + // User is redirected to the GitLab IDP + // User authenticates with the GitLab IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Gitlab IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Gitlab IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Gitlab IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-google.spec.ts b/login/apps/login-test-acceptance/tests/idp-google.spec.ts new file mode 100644 index 0000000000..8eb4d54e34 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-google.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Google IDP", async ({ page }) => { + test.skip(); + // Given a Google IDP is configured on the organization + // Given the user has Google IDP added as auth method + // User authenticates with the Google IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Google IDP - error", async ({ page }) => { + test.skip(); + // Given the Google IDP is configured on the organization + // Given the user has Google IDP added as auth method + // User is redirected to the Google IDP + // User authenticates with the Google IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Google IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Google IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Google IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts b/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts new file mode 100644 index 0000000000..0705ed45f8 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with LDAP IDP", async ({ page }) => { + test.skip(); + // Given a LDAP IDP is configured on the organization + // Given the user has LDAP IDP added as auth method + // User authenticates with the LDAP IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with LDAP IDP - error", async ({ page }) => { + test.skip(); + // Given the LDAP IDP is configured on the organization + // Given the user has LDAP IDP added as auth method + // User is redirected to the LDAP IDP + // User authenticates with the LDAP IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with LDAP IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with LDAP IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with LDAP IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts b/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts new file mode 100644 index 0000000000..15d67c28aa --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts @@ -0,0 +1,102 @@ +// Note for all tests, in case Microsoft doesn't deliver all relevant information per default +// We should add an action in the needed cases + +import test from "@playwright/test"; + +test("login with Microsoft IDP", async ({ page }) => { + test.skip(); + // Given a Microsoft IDP is configured on the organization + // Given the user has Microsoft IDP added as auth method + // User authenticates with the Microsoft IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Microsoft IDP - error", async ({ page }) => { + test.skip(); + // Given the Microsoft IDP is configured on the organization + // Given the user has Microsoft IDP added as auth method + // User is redirected to the Microsoft IDP + // User authenticates with the Microsoft IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Microsoft IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Microsoft IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Microsoft IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-saml.spec.ts b/login/apps/login-test-acceptance/tests/idp-saml.spec.ts new file mode 100644 index 0000000000..90d8d618b4 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-saml.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with SAML IDP", async ({ page }) => { + test.skip(); + // Given a SAML IDP is configured on the organization + // Given the user has SAML IDP added as auth method + // User authenticates with the SAML IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with SAML IDP - error", async ({ page }) => { + test.skip(); + // Given the SAML IDP is configured on the organization + // Given the user has SAML IDP added as auth method + // User is redirected to the SAML IDP + // User authenticates with the SAML IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with SAML IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with SAML IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with SAML IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts b/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts new file mode 100644 index 0000000000..cc58dbcc71 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts @@ -0,0 +1,57 @@ +import test from "@playwright/test"; + +test("login with mfa setup, mfa setup prompt", async ({ page }) => { + test.skip(); + // Given the organization has enabled at least one mfa types + // Given the user has a password but no mfa registered + // User authenticates with login name and password + // User is prompted to setup a mfa, mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, no mfa setup prompt", async ({ page }) => { + test.skip(); + // Given the organization has set "multifactor init check time" to 0 + // Given the organization has enabled mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // user is directly loged in and not prompted to setup mfa +}); + +test("login with mfa setup, force mfa for local authenticated users", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa for local authentiacted users + // Given the organization has enabled all possible mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - local user", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa for local authentiacted users + // Given the organization has enabled all possible mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - external user", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa + // Given the organization has enabled all possible mfa types + // Given the user has an idp but no mfa registered + // enter login name + // redirect to configured external idp + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - local user, wrong password", async ({ page }) => { + test.skip(); + // Given the organization has a password lockout policy set to 1 on the max password attempts + // Given the user has only a password as auth methos + // enter login name + // enter wrong password + // User will get an error "Wrong password" + // enter password + // User will get an error "Max password attempts reached - user is locked. Please reach out to your administrator" +}); diff --git a/login/apps/login-test-acceptance/tests/login.ts b/login/apps/login-test-acceptance/tests/login.ts new file mode 100644 index 0000000000..2076412456 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/login.ts @@ -0,0 +1,41 @@ +import { expect, Page } from "@playwright/test"; +import { code, otpFromSink } from "./code"; +import { loginname } from "./loginname"; +import { password } from "./password"; +import { totp } from "./zitadel"; + +export async function startLogin(page: Page) { + await page.goto(`./loginname`); +} + +export async function loginWithPassword(page: Page, username: string, pw: string) { + await startLogin(page); + await loginname(page, username); + await password(page, pw); +} + +export async function loginWithPasskey(page: Page, authenticatorId: string, username: string) { + await startLogin(page); + await loginname(page, username); + // await passkey(page, authenticatorId); +} + +export async function loginScreenExpect(page: Page, fullName: string) { + await expect(page).toHaveURL(/.*signedin.*/); + await expect(page.getByRole("heading")).toContainText(fullName); +} + +export async function loginWithPasswordAndEmailOTP(page: Page, username: string, password: string, email: string) { + await loginWithPassword(page, username, password); + await otpFromSink(page, email); +} + +export async function loginWithPasswordAndPhoneOTP(page: Page, username: string, password: string, phone: string) { + await loginWithPassword(page, username, password); + await otpFromSink(page, phone); +} + +export async function loginWithPasswordAndTOTP(page: Page, username: string, password: string, secret: string) { + await loginWithPassword(page, username, password); + await code(page, totp(secret)); +} diff --git a/login/apps/login-test-acceptance/tests/loginname-screen.ts b/login/apps/login-test-acceptance/tests/loginname-screen.ts new file mode 100644 index 0000000000..be41a28eda --- /dev/null +++ b/login/apps/login-test-acceptance/tests/loginname-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const usernameTextInput = "username-text-input"; + +export async function loginnameScreen(page: Page, username: string) { + await page.getByTestId(usernameTextInput).pressSequentially(username); +} + +export async function loginnameScreenExpect(page: Page, username: string) { + await expect(page.getByTestId(usernameTextInput)).toHaveValue(username); + await expect(page.getByTestId("error").locator("div")).toContainText("User not found in the system"); +} diff --git a/login/apps/login-test-acceptance/tests/loginname.ts b/login/apps/login-test-acceptance/tests/loginname.ts new file mode 100644 index 0000000000..2050ec1d3c --- /dev/null +++ b/login/apps/login-test-acceptance/tests/loginname.ts @@ -0,0 +1,7 @@ +import { Page } from "@playwright/test"; +import { loginnameScreen } from "./loginname-screen"; + +export async function loginname(page: Page, username: string) { + await loginnameScreen(page, username); + await page.getByTestId("submit-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/passkey.ts b/login/apps/login-test-acceptance/tests/passkey.ts new file mode 100644 index 0000000000..d8cda10ddb --- /dev/null +++ b/login/apps/login-test-acceptance/tests/passkey.ts @@ -0,0 +1,109 @@ +import { expect, Page } from "@playwright/test"; +import { CDPSession } from "playwright-core"; + +interface session { + client: CDPSession; + authenticatorId: string; +} + +async function client(page: Page): Promise { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable", { enableUI: false }); + const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + return { client: cdpSession, authenticatorId: result.authenticatorId }; +} + +export async function passkeyRegister(page: Page): Promise { + const session = await client(page); + + await passkeyNotExisting(session.client, session.authenticatorId); + await simulateSuccessfulPasskeyRegister(session.client, session.authenticatorId, () => + page.getByTestId("submit-button").click(), + ); + await passkeyRegistered(session.client, session.authenticatorId); + + return session.authenticatorId; +} + +export async function passkey(page: Page, authenticatorId: string) { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable", { enableUI: false }); + + const signCount = await passkeyExisting(cdpSession, authenticatorId); + + await simulateSuccessfulPasskeyInput(cdpSession, authenticatorId, () => page.getByTestId("submit-button").click()); + + await passkeyUsed(cdpSession, authenticatorId, signCount); +} + +async function passkeyNotExisting(client: CDPSession, authenticatorId: string) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(0); +} + +async function passkeyRegistered(client: CDPSession, authenticatorId: string) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + await passkeyUsed(client, authenticatorId, 0); +} + +async function passkeyExisting(client: CDPSession, authenticatorId: string): Promise { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + return result.credentials[0].signCount; +} + +async function passkeyUsed(client: CDPSession, authenticatorId: string, signCount: number) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0].signCount).toBeGreaterThan(signCount); +} + +async function simulateSuccessfulPasskeyRegister( + client: CDPSession, + authenticatorId: string, + operationTrigger: () => Promise, +) { + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise((resolve) => { + client.on("WebAuthn.credentialAdded", () => { + console.log("Credential Added!"); + resolve(); + }); + }); + + // perform a user action that triggers passkey prompt + await operationTrigger(); + + // wait to receive the event that the passkey was successfully registered or verified + await operationCompleted; +} + +async function simulateSuccessfulPasskeyInput( + client: CDPSession, + authenticatorId: string, + operationTrigger: () => Promise, +) { + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise((resolve) => { + client.on("WebAuthn.credentialAsserted", () => { + console.log("Credential Asserted!"); + resolve(); + }); + }); + + // perform a user action that triggers passkey prompt + await operationTrigger(); + + // wait to receive the event that the passkey was successfully registered or verified + await operationCompleted; +} diff --git a/login/apps/login-test-acceptance/tests/password-screen.ts b/login/apps/login-test-acceptance/tests/password-screen.ts new file mode 100644 index 0000000000..fda6f6d39f --- /dev/null +++ b/login/apps/login-test-acceptance/tests/password-screen.ts @@ -0,0 +1,98 @@ +import { expect, Page } from "@playwright/test"; +import { getCodeFromSink } from "./sink"; + +const codeField = "code-text-input"; +const passwordField = "password-text-input"; +const passwordChangeField = "password-change-text-input"; +const passwordChangeConfirmField = "password-change-confirm-text-input"; +const passwordSetField = "password-set-text-input"; +const passwordSetConfirmField = "password-set-confirm-text-input"; +const lengthCheck = "length-check"; +const symbolCheck = "symbol-check"; +const numberCheck = "number-check"; +const uppercaseCheck = "uppercase-check"; +const lowercaseCheck = "lowercase-check"; +const equalCheck = "equal-check"; + +const matchText = "Matches"; +const noMatchText = "Doesn't match"; + +export async function changePasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId(passwordChangeField).pressSequentially(password1); + await page.getByTestId(passwordChangeConfirmField).pressSequentially(password2); +} + +export async function passwordScreen(page: Page, password: string) { + await page.getByTestId(passwordField).pressSequentially(password); +} + +export async function passwordScreenExpect(page: Page, password: string) { + await expect(page.getByTestId(passwordField)).toHaveValue(password); + await expect(page.getByTestId("error").locator("div")).toContainText("Failed to authenticate."); +} + +export async function changePasswordScreenExpect( + page: Page, + password1: string, + password2: string, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await expect(page.getByTestId(passwordChangeField)).toHaveValue(password1); + await expect(page.getByTestId(passwordChangeConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} + +async function checkComplexity( + page: Page, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await checkContent(page, lengthCheck, length); + await checkContent(page, symbolCheck, symbol); + await checkContent(page, numberCheck, number); + await checkContent(page, uppercaseCheck, uppercase); + await checkContent(page, lowercaseCheck, lowercase); + await checkContent(page, equalCheck, equals); +} + +async function checkContent(page: Page, testid: string, match: boolean) { + if (match) { + await expect(page.getByTestId(testid)).toContainText(matchText); + } else { + await expect(page.getByTestId(testid)).toContainText(noMatchText); + } +} + +export async function resetPasswordScreen(page: Page, username: string, password1: string, password2: string) { + const c = await getCodeFromSink(username); + await page.getByTestId(codeField).pressSequentially(c); + await page.getByTestId(passwordSetField).pressSequentially(password1); + await page.getByTestId(passwordSetConfirmField).pressSequentially(password2); +} + +export async function resetPasswordScreenExpect( + page: Page, + password1: string, + password2: string, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await expect(page.getByTestId(passwordSetField)).toHaveValue(password1); + await expect(page.getByTestId(passwordSetConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} diff --git a/login/apps/login-test-acceptance/tests/password.ts b/login/apps/login-test-acceptance/tests/password.ts new file mode 100644 index 0000000000..ccf3e509d9 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/password.ts @@ -0,0 +1,29 @@ +import { Page } from "@playwright/test"; +import { changePasswordScreen, passwordScreen, resetPasswordScreen } from "./password-screen"; + +const passwordSubmitButton = "submit-button"; +const passwordResetButton = "reset-button"; + +export async function startChangePassword(page: Page, loginname: string) { + await page.goto("./password/change?" + new URLSearchParams({ loginName: loginname })); +} + +export async function changePassword(page: Page, password: string) { + await changePasswordScreen(page, password, password); + await page.getByTestId(passwordSubmitButton).click(); +} + +export async function password(page: Page, password: string) { + await passwordScreen(page, password); + await page.getByTestId(passwordSubmitButton).click(); +} + +export async function startResetPassword(page: Page) { + await page.getByTestId(passwordResetButton).click(); +} + +export async function resetPassword(page: Page, username: string, password: string) { + await startResetPassword(page); + await resetPasswordScreen(page, username, password, password); + await page.getByTestId(passwordSubmitButton).click(); +} diff --git a/login/apps/login-test-acceptance/tests/register-screen.ts b/login/apps/login-test-acceptance/tests/register-screen.ts new file mode 100644 index 0000000000..d14f5dc970 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register-screen.ts @@ -0,0 +1,27 @@ +import { Page } from "@playwright/test"; + +const passwordField = "password-text-input"; +const passwordConfirmField = "password-confirm-text-input"; + +export async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) { + await registerUserScreen(page, firstname, lastname, email); + await page.getByTestId("password-radio").click(); +} + +export async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) { + await registerUserScreen(page, firstname, lastname, email); + await page.getByTestId("passkey-radio").click(); +} + +export async function registerPasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId(passwordField).pressSequentially(password1); + await page.getByTestId(passwordConfirmField).pressSequentially(password2); +} + +export async function registerUserScreen(page: Page, firstname: string, lastname: string, email: string) { + await page.getByTestId("firstname-text-input").pressSequentially(firstname); + await page.getByTestId("lastname-text-input").pressSequentially(lastname); + await page.getByTestId("email-text-input").pressSequentially(email); + await page.getByTestId("privacy-policy-checkbox").check(); + await page.getByTestId("tos-checkbox").check(); +} diff --git a/login/apps/login-test-acceptance/tests/register.spec.ts b/login/apps/login-test-acceptance/tests/register.spec.ts new file mode 100644 index 0000000000..4ad7e9e349 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register.spec.ts @@ -0,0 +1,183 @@ +import { faker } from "@faker-js/faker"; +import { test } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect } from "./login"; +import { registerWithPasskey, registerWithPassword } from "./register"; +import { removeUserByUsername } from "./zitadel"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +test("register with password", async ({ page }) => { + const username = faker.internet.email(); + const password = "Password1!"; + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + + await registerWithPassword(page, firstname, lastname, username, password, password); + await loginScreenExpect(page, firstname + " " + lastname); + + // wait for projection of user + await page.waitForTimeout(10000); + await removeUserByUsername(username); +}); + +test("register with passkey", async ({ page }) => { + const username = faker.internet.email(); + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + + await registerWithPasskey(page, firstname, lastname, username); + await loginScreenExpect(page, firstname + " " + lastname); + + // wait for projection of user + await page.waitForTimeout(10000); + await removeUserByUsername(username); +}); + +test("register with username and password - only password enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and "password" + // User is redirected to app (default redirect url) +}); + +test("register with username and password - wrong password not enough characters", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password thats to short + // Error is shown "Password doesn't match the policy - it must have at least 8 characters" +}); + +test("register with username and password - wrong password number missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without a number + // Error is shown "Password doesn't match the policy - number missing" +}); + +test("register with username and password - wrong password upper case missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an upper case + // Error is shown "Password doesn't match the policy - uppercase letter missing" +}); + +test("register with username and password - wrong password lower case missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an lower case + // Error is shown "Password doesn't match the policy - lowercase letter missing" +}); + +test("register with username and password - wrong password symboo missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an symbol + // Error is shown "Password doesn't match the policy - symbol missing" +}); + +test("register with username and password - password and passkey enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // User enters "firstname", "lastname", "username" + // Password and passkey are shown as authentication option + // User clicks password + // User enters password + // User is redirected to app (default redirect url) +}); + +test("register with username and passkey - password and passkey enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // User enters "firstname", "lastname", "username" + // Password and passkey are shown as authentication option + // User clicks passkey + // Passkey is opened automatically + // User verifies passkey + // User is redirected to app (default redirect url) +}); + +test("register with username and password - registration disabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given user doesn't exist + // Button "register new user" is not available +}); + +test("register with username and password - multiple registration options", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization one idp is configured and enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration options + // Local User and idp button are shown + // User clicks idp button + // User enters "firstname", "lastname", "username" and "password" + // User clicks next + // User is redirected to app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/register.ts b/login/apps/login-test-acceptance/tests/register.ts new file mode 100644 index 0000000000..164a72753b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register.ts @@ -0,0 +1,39 @@ +import { Page } from "@playwright/test"; +import { emailVerify } from "./email-verify"; +import { passkeyRegister } from "./passkey"; +import { registerPasswordScreen, registerUserScreenPasskey, registerUserScreenPassword } from "./register-screen"; +import { getCodeFromSink } from "./sink"; + +export async function registerWithPassword( + page: Page, + firstname: string, + lastname: string, + email: string, + password1: string, + password2: string, +) { + await page.goto("./register"); + await registerUserScreenPassword(page, firstname, lastname, email); + await page.getByTestId("submit-button").click(); + await registerPasswordScreen(page, password1, password2); + await page.getByTestId("submit-button").click(); + await verifyEmail(page, email); +} + +export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string): Promise { + await page.goto("./register"); + await registerUserScreenPasskey(page, firstname, lastname, email); + await page.getByTestId("submit-button").click(); + + // wait for projection of user + await page.waitForTimeout(10000); + const authId = await passkeyRegister(page); + + await verifyEmail(page, email); + return authId; +} + +async function verifyEmail(page: Page, email: string) { + const c = await getCodeFromSink(email); + await emailVerify(page, c); +} diff --git a/login/apps/login-test-acceptance/tests/select-account.ts b/login/apps/login-test-acceptance/tests/select-account.ts new file mode 100644 index 0000000000..64bd7cd145 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/select-account.ts @@ -0,0 +1,5 @@ +import { Page } from "@playwright/test"; + +export async function selectNewAccount(page: Page) { + await page.getByRole("link", { name: "Add another account" }).click(); +} diff --git a/login/apps/login-test-acceptance/tests/sink.ts b/login/apps/login-test-acceptance/tests/sink.ts new file mode 100644 index 0000000000..bc3336b358 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/sink.ts @@ -0,0 +1,43 @@ +import { Gaxios, GaxiosResponse } from "gaxios"; + +const awaitNotification = new Gaxios({ + url: process.env.SINK_NOTIFICATION_URL, + method: "POST", + retryConfig: { + httpMethodsToRetry: ["POST"], + statusCodesToRetry: [[404, 404]], + retry: Number.MAX_SAFE_INTEGER, // totalTimeout limits the number of retries + totalTimeout: 10000, // 10 seconds + onRetryAttempt: (error) => { + console.warn(`Retrying request to sink notification service: ${error.message}`); + }, + }, +}); + +export async function getOtpFromSink(recipient: string): Promise { + return awaitNotification.request({ data: { recipient } }).then((response) => { + expectSuccess(response); + const otp = response?.data?.args?.otp; + if (!otp) { + throw new Error(`Response does not contain an otp property: ${JSON.stringify(response.data, null, 2)}`); + } + return otp; + }); +} + +export async function getCodeFromSink(recipient: string): Promise { + return awaitNotification.request({ data: { recipient } }).then((response) => { + expectSuccess(response); + const code = response?.data?.args?.code; + if (!code) { + throw new Error(`Response does not contain a code property: ${JSON.stringify(response.data, null, 2)}`); + } + return code; + }); +} + +function expectSuccess(response: GaxiosResponse): void { + if (response.status !== 200) { + throw new Error(`Expected HTTP status 200, but got: ${response.status} - ${response.statusText}`); + } +} diff --git a/login/apps/login-test-acceptance/tests/user.ts b/login/apps/login-test-acceptance/tests/user.ts new file mode 100644 index 0000000000..3b03291408 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/user.ts @@ -0,0 +1,177 @@ +import { Page } from "@playwright/test"; +import { registerWithPasskey } from "./register"; +import { activateOTP, addTOTP, addUser, eventualNewUser, getUserByUsername, removeUser } from "./zitadel"; + +export interface userProps { + email: string; + isEmailVerified?: boolean; + firstName: string; + lastName: string; + organization: string; + password: string; + passwordChangeRequired?: boolean; + phone: string; + isPhoneVerified?: boolean; +} + +class User { + private readonly props: userProps; + private user: string; + + constructor(userProps: userProps) { + this.props = userProps; + } + + async ensure(page: Page) { + const response = await addUser(this.props); + + this.setUserId(response.userId); + } + + async cleanup() { + await removeUser(this.getUserId()); + } + + public setUserId(userId: string) { + this.user = userId; + } + + public getUserId() { + return this.user; + } + + public getUsername() { + return this.props.email; + } + + public getPassword() { + return this.props.password; + } + + public getFirstname() { + return this.props.firstName; + } + + public getLastname() { + return this.props.lastName; + } + + public getPhone() { + return this.props.phone; + } + + public getFullName() { + return `${this.props.firstName} ${this.props.lastName}`; + } +} + +export class PasswordUser extends User { + async ensure(page: Page) { + await super.ensure(page); + await eventualNewUser(this.getUserId()); + } +} + +export enum OtpType { + sms = "sms", + email = "email", +} + +export interface otpUserProps { + email: string; + isEmailVerified?: boolean; + firstName: string; + lastName: string; + organization: string; + password: string; + passwordChangeRequired?: boolean; + phone: string; + isPhoneVerified?: boolean; + type: OtpType; +} + +export class PasswordUserWithOTP extends User { + private type: OtpType; + + constructor(props: otpUserProps) { + super({ + email: props.email, + firstName: props.firstName, + lastName: props.lastName, + organization: props.organization, + password: props.password, + phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + passwordChangeRequired: props.passwordChangeRequired, + }); + this.type = props.type; + } + + async ensure(page: Page) { + await super.ensure(page); + await activateOTP(this.getUserId(), this.type); + await eventualNewUser(this.getUserId()); + } +} + +export class PasswordUserWithTOTP extends User { + private secret: string; + + async ensure(page: Page) { + await super.ensure(page); + this.secret = await addTOTP(this.getUserId()); + await eventualNewUser(this.getUserId()); + } + + public getSecret(): string { + return this.secret; + } +} + +export interface passkeyUserProps { + email: string; + firstName: string; + lastName: string; + organization: string; + phone: string; + isEmailVerified?: boolean; + isPhoneVerified?: boolean; +} + +export class PasskeyUser extends User { + private authenticatorId: string; + + constructor(props: passkeyUserProps) { + super({ + email: props.email, + firstName: props.firstName, + lastName: props.lastName, + organization: props.organization, + password: "", + phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + }); + } + + public async ensure(page: Page) { + const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername()); + this.authenticatorId = authId; + + // wait for projection of user + await page.waitForTimeout(10000); + } + + async cleanup() { + const resp: any = await getUserByUsername(this.getUsername()); + if (!resp || !resp.result || !resp.result[0]) { + return; + } + await removeUser(resp.result[0].userId); + } + + public getAuthenticatorId(): string { + return this.authenticatorId; + } +} diff --git a/login/apps/login-test-acceptance/tests/username-passkey.spec.ts b/login/apps/login-test-acceptance/tests/username-passkey.spec.ts new file mode 100644 index 0000000000..dff1c65f5a --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-passkey.spec.ts @@ -0,0 +1,43 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPasskey } from "./login"; +import { PasskeyUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasskeyUser }>({ + user: async ({ page }, use) => { + const user = new PasskeyUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and passkey login", async ({ user, page }) => { + await loginWithPasskey(page, user.getAuthenticatorId(), user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username and passkey login, multiple auth methods", async ({ page }) => { + test.skip(); + // Given passkey and password is enabled on the organization of the user + // Given the user has password and passkey registered + // enter username + // passkey popup is directly shown + // user aborts passkey authentication + // user switches to password authentication + // user enters password + // user is redirected to app +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts b/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts new file mode 100644 index 0000000000..50605e5ff0 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts @@ -0,0 +1,41 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword } from "./password"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: true, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login, change required", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await page.waitForTimeout(10000); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts b/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts new file mode 100644 index 0000000000..dc29dc2286 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts @@ -0,0 +1,54 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword, startChangePassword } from "./password"; +import { changePasswordScreen, changePasswordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password changed login", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + + // wait for projection of token + await page.waitForTimeout(10000); + + await startChangePassword(page, user.getUsername()); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); + +test("password change not with desired complexity", async ({ user, page }) => { + const changedPw1 = "change"; + const changedPw2 = "chang"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await startChangePassword(page, user.getUsername()); + await changePasswordScreen(page, changedPw1, changedPw2); + await changePasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts b/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts new file mode 100644 index 0000000000..e4a77751c1 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts @@ -0,0 +1,98 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code, codeResend, otpFromSink } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndEmailOTP } from "./login"; +import { OtpType, PasswordUserWithOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + type: OtpType.email, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test.skip("DOESN'T WORK: username, password and email otp login, enter code manually", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndEmailOTP(page, user.getUsername(), user.getPassword(), user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and email otp login, click link in email", async ({ page }) => { + base.skip(); + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks link in the email + // User is redirected to the app (default redirect url) +}); + +test.skip("DOESN'T WORK: username, password and email otp login, resend code", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks resend code + // User receives a new email with a verification code + // User enters the new code in the ui + // User is redirected to the app (default redirect url) + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await codeResend(page); + await otpFromSink(page, user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and email otp login, wrong code", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User enters a wrong code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); + +test("username, password and email otp login, multiple mfa options", async ({ page }) => { + base.skip(); + // Given email otp and sms otp is enabled on the organization of the user + // Given the user has email and sms otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks button to use sms otp as second factor + // User receives a sms with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts b/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts new file mode 100644 index 0000000000..10901cd243 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts @@ -0,0 +1,71 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndPhoneOTP } from "./login"; +import { OtpType, PasswordUserWithOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, + password: "Password1!", + passwordChangeRequired: false, + type: OtpType.sms, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test.skip("DOESN'T WORK: username, password and sms otp login, enter code manually", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone()); + await loginScreenExpect(page, user.getFullName()); +}); + +test.skip("DOESN'T WORK: username, password and sms otp login, resend code", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User clicks resend code + // User receives a new sms with a verification code + // User is redirected to the app (default redirect url) + await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and sms otp login, wrong code", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User enters a wrong code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-set.spec.ts b/login/apps/login-test-acceptance/tests/username-password-set.spec.ts new file mode 100644 index 0000000000..06ce42f1a7 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-set.spec.ts @@ -0,0 +1,52 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { resetPassword, startResetPassword } from "./password"; +import { resetPasswordScreen, resetPasswordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password set login", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + await startLogin(page); + await loginname(page, user.getUsername()); + await resetPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); + +test("password set not with desired complexity", async ({ user, page }) => { + const changedPw1 = "change"; + const changedPw2 = "chang"; + await startLogin(page); + await loginname(page, user.getUsername()); + await startResetPassword(page); + await resetPasswordScreen(page, user.getUsername(), changedPw1, changedPw2); + await resetPasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts b/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts new file mode 100644 index 0000000000..e495b16681 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts @@ -0,0 +1,71 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndTOTP } from "./login"; +import { PasswordUserWithTOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithTOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, + password: "Password1!", + passwordChangeRequired: false, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username, password and totp login", async ({ user, page }) => { + // Given totp is enabled on the organization of the user + // Given the user has only totp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndTOTP(page, user.getUsername(), user.getPassword(), user.getSecret()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and totp otp login, wrong code", async ({ user, page }) => { + // Given totp is enabled on the organization of the user + // Given the user has only totp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // User enters a wrond code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); + +test("username, password and totp login, multiple mfa options", async ({ page }) => { + test.skip(); + // Given totp and email otp is enabled on the organization of the user + // Given the user has totp and email otp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // Button to switch to email otp is shown + // User clicks button to use email otp instead + // User receives an email with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts b/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts new file mode 100644 index 0000000000..dc23064fd6 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts @@ -0,0 +1,26 @@ +import { test } from "@playwright/test"; + +test("username, password and u2f login", async ({ page }) => { + test.skip(); + // Given u2f is enabled on the organization of the user + // Given the user has only u2f configured as second factor + // User enters username + // User enters password + // Popup for u2f is directly opened + // User verifies u2f + // User is redirected to the app (default redirect url) +}); + +test("username, password and u2f login, multiple mfa options", async ({ page }) => { + test.skip(); + // Given u2f and semailms otp is enabled on the organization of the user + // Given the user has u2f and email otp configured as second factor + // User enters username + // User enters password + // Popup for u2f is directly opened + // User aborts u2f verification + // User clicks button to use email otp as second factor + // User receives an email with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password.spec.ts b/login/apps/login-test-acceptance/tests/username-password.spec.ts new file mode 100644 index 0000000000..ceb340f8da --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password.spec.ts @@ -0,0 +1,157 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { loginnameScreenExpect } from "./loginname-screen"; +import { password } from "./password"; +import { passwordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username and password login, unknown username", async ({ page }) => { + const username = "unknown"; + await startLogin(page); + await loginname(page, username); + await loginnameScreenExpect(page, username); +}); + +test("username and password login, wrong password", async ({ user, page }) => { + await startLogin(page); + await loginname(page, user.getUsername()); + await password(page, "wrong"); + await passwordScreenExpect(page, "wrong"); +}); + +test("username and password login, wrong username, ignore unknown usernames", async ({ user, page }) => { + test.skip(); + // Given user doesn't exist but ignore unknown usernames setting is set to true + // Given username password login is enabled on the users organization + // enter login name + // enter password + // redirect to loginname page --> error message username or password wrong +}); + +test("username and password login, initial password change", async ({ user, page }) => { + test.skip(); + // Given user is created and has changePassword set to true + // Given username password login is enabled on the users organization + // enter login name + // enter password + // create new password +}); + +test("username and password login, reset password hidden", async ({ user, page }) => { + test.skip(); + // Given the organization has enabled "Password reset hidden" in the login policy + // Given username password login is enabled on the users organization + // enter login name + // password reset link should not be shown on password screen +}); + +test("username and password login, reset password - enter code manually", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // enter code from email + // user is redirected to app (default redirect url) +}); + +test("username and password login, reset password - click link", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button, and then the link in the email + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // click link in email + // set new password + // redirect to app (default redirect url) +}); + +test("username and password login, reset password, resend code", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button and then resend code + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // click resend code + // enter code from second email + // user is redirected to app (default redirect url) +}); + +test("email login enabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same email address exists + // enter email address "test@zitadel.com " in login screen + // user will get to password screen +}); + +test("email login disabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same email address exists + // enter email address "test@zitadel.com" in login screen + // user will see error message "user not found" +}); + +test("email login enabled - multiple users", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists + // enter email address "test@zitadel.com" in login screen + // user will see error message "user not found" +}); + +test("phone login enabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same phon number exists + // enter phone number "0711111111" in login screen + // user will get to password screen +}); + +test("phone login disabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same phone number exists + // enter phone number "0711111111" in login screen + // user will see error message "user not found" +}); + +test("phone login enabled - multiple users", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists + // enter phone number "0711111111" in login screen + // user will see error message "user not found" +}); diff --git a/login/apps/login-test-acceptance/tests/welcome.ts b/login/apps/login-test-acceptance/tests/welcome.ts new file mode 100644 index 0000000000..34267c2bd0 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/welcome.ts @@ -0,0 +1,6 @@ +import { test } from "@playwright/test"; + +test("login is accessible", async ({ page }) => { + await page.goto("./"); + await page.getByRole("heading", { name: "Welcome back!" }).isVisible(); +}); diff --git a/login/apps/login-test-acceptance/tests/zitadel.ts b/login/apps/login-test-acceptance/tests/zitadel.ts new file mode 100644 index 0000000000..b252654f86 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/zitadel.ts @@ -0,0 +1,190 @@ +import { Authenticator } from "@otplib/core"; +import { createDigest, createRandomBytes } from "@otplib/plugin-crypto"; +import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two"; // use your chosen base32 plugin +import axios from "axios"; +import dotenv from "dotenv"; +import { request } from "gaxios"; +import path from "path"; +import { OtpType, userProps } from "./user"; + +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +export async function addUser(props: userProps) { + const body = { + username: props.email, + organization: { + orgId: props.organization, + }, + profile: { + givenName: props.firstName, + familyName: props.lastName, + }, + email: { + email: props.email, + isVerified: true, + }, + phone: { + phone: props.phone, + isVerified: true, + }, + password: { + password: props.password, + changeRequired: props.passwordChangeRequired ?? false, + }, + }; + if (!props.isEmailVerified) { + delete body.email.isVerified; + } + if (!props.isPhoneVerified) { + delete body.phone.isVerified; + } + + return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body); +} + +export async function removeUserByUsername(username: string) { + const resp = await getUserByUsername(username); + if (!resp || !resp.result || !resp.result[0]) { + return; + } + await removeUser(resp.result[0].userId); +} + +export async function removeUser(id: string) { + await deleteCall(`${process.env.ZITADEL_API_URL}/v2/users/${id}`); +} + +async function deleteCall(url: string) { + try { + const response = await axios.delete(url, { + headers: { + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400 && response.status !== 404) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function getUserByUsername(username: string): Promise { + const listUsersBody = { + queries: [ + { + userNameQuery: { + userName: username, + }, + }, + ], + }; + + return await listCall(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody); +} + +async function listCall(url: string, data: any): Promise { + try { + const response = await axios.post(url, data, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + + return response.data; + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function activateOTP(userId: string, type: OtpType) { + let url = "otp_"; + switch (type) { + case OtpType.sms: + url = url + "sms"; + break; + case OtpType.email: + url = url + "email"; + break; + } + + await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/${url}`, {}); +} + +async function pushCall(url: string, data: any) { + try { + const response = await axios.post(url, data, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function addTOTP(userId: string): Promise { + const response = await listCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp`, {}); + const code = totp(response.secret); + await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp/verify`, { code: code }); + return response.secret; +} + +export function totp(secret: string) { + const authenticator = new Authenticator({ + createDigest, + createRandomBytes, + keyDecoder, + keyEncoder, + }); + // google authenticator usage + const token = authenticator.generate(secret); + + // check if token can be used + if (!authenticator.verify({ token: token, secret: secret })) { + const error = `Generated token could not be verified`; + console.error(error); + throw new Error(error); + } + + return token; +} + +export async function eventualNewUser(id: string) { + return request({ + url: `${process.env.ZITADEL_API_URL}/v2/users/${id}`, + method: "GET", + headers: { + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + "Content-Type": "application/json", + }, + retryConfig: { + statusCodesToRetry: [[404, 404]], + retry: Number.MAX_SAFE_INTEGER, // totalTimeout limits the number of retries + totalTimeout: 10000, // 10 seconds + onRetryAttempt: (error) => { + console.warn(`Retrying to query new user ${id}: ${error.message}`); + }, + }, + }); +} diff --git a/login/apps/login-test-acceptance/turbo.json b/login/apps/login-test-acceptance/turbo.json new file mode 100644 index 0000000000..3be0539d0f --- /dev/null +++ b/login/apps/login-test-acceptance/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "test:acceptance:setup:dev": { + "interactive": true, + "cache": false, + "persistent": true + } + } +} diff --git a/login/apps/login-test-acceptance/zitadel.yaml b/login/apps/login-test-acceptance/zitadel.yaml new file mode 100644 index 0000000000..3ddeaf67f0 --- /dev/null +++ b/login/apps/login-test-acceptance/zitadel.yaml @@ -0,0 +1,83 @@ +ExternalDomain: 127.0.0.1.sslip.io +ExternalSecure: true +ExternalPort: 443 +TLS.Enabled: false + +FirstInstance: + PatPath: /pat/zitadel-admin-sa.pat + Org: + Human: + UserName: zitadel-admin + FirstName: ZITADEL + LastName: Admin + Password: Password1! + PasswordChangeRequired: false + PreferredLanguage: en + Machine: + Machine: + Username: zitadel-admin-sa + Name: Admin + Pat: + ExpirationDate: 2099-01-01T00:00:00Z + +DefaultInstance: + LoginPolicy: + AllowUsernamePassword: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWUSERNAMEPASSWORD + AllowRegister: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWREGISTER + AllowExternalIDP: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWEXTERNALIDP + ForceMFA: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_FORCEMFA + HidePasswordReset: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_HIDEPASSWORDRESET + IgnoreUnknownUsernames: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_IGNOREUNKNOWNUSERNAMES + AllowDomainDiscovery: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWDOMAINDISCOVERY + # 1 is allowed, 0 is not allowed + PasswordlessType: 1 # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDLESSTYPE + # DefaultRedirectURL is empty by default because we use the Console UI + DefaultRedirectURI: # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_DEFAULTREDIRECTURI + # 240h = 10d + PasswordCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDCHECKLIFETIME + # 240h = 10d + ExternalLoginCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_EXTERNALLOGINCHECKLIFETIME + # 720h = 30d + MfaInitSkipLifetime: 0h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MFAINITSKIPLIFETIME + SecondFactorCheckLifetime: 18h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_SECONDFACTORCHECKLIFETIME + MultiFactorCheckLifetime: 12h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MULTIFACTORCHECKLIFETIME + PrivacyPolicy: + TOSLink: "https://zitadel.com/docs/legal/terms-of-service" + PrivacyLink: "https://zitadel.com/docs/legal/policies/privacy-policy" + HelpLink: "https://zitadel.com/docs" + SupportEmail: "support@zitadel.com" + DocsLink: "https://zitadel.com/docs" + Features: + LoginV2: + Required: true + +OIDC: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" + +SAML: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" + +Database: + EventPushConnRatio: 0.2 # 4 + ProjectionSpoolerConnRatio: 0.3 # 6 + postgres: + Host: db + Port: 5432 + Database: zitadel + MaxOpenConns: 20 + MaxIdleConns: 20 + MaxConnLifetime: 1h + MaxConnIdleTime: 5m + User: + Username: zitadel + SSL: + Mode: disable + Admin: + Username: zitadel + SSL: + Mode: disable + +Logstore: + Access: + Stdout: + Enabled: true diff --git a/login/apps/login-test-integration/.gitignore b/login/apps/login-test-integration/.gitignore new file mode 100644 index 0000000000..2ca81ab137 --- /dev/null +++ b/login/apps/login-test-integration/.gitignore @@ -0,0 +1,2 @@ +screenshots +videos \ No newline at end of file diff --git a/login/apps/login-test-integration/core-mock/Dockerfile b/login/apps/login-test-integration/core-mock/Dockerfile new file mode 100644 index 0000000000..469147d17d --- /dev/null +++ b/login/apps/login-test-integration/core-mock/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.20.5-alpine3.18 + +RUN go install github.com/eliobischof/grpc-mock/cmd/grpc-mock@01b09f60db1b501178af59bed03b2c22661df48c + +COPY mocked-services.cfg . +COPY initial-stubs initial-stubs +COPY --from=protos . . + +ENTRYPOINT [ "sh", "-c", "grpc-mock -v 1 -proto $(tr '\n' ',' < ./mocked-services.cfg) -stub-dir ./initial-stubs -mock-addr :22222" ] diff --git a/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json b/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json new file mode 100644 index 0000000000..3da4ae999f --- /dev/null +++ b/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json @@ -0,0 +1,59 @@ +[ + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetBrandingSettings", + "out": { + "data": {} + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetSecuritySettings", + "out": { + "data": {} + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetLegalAndSupportSettings", + "out": { + "data": { + "settings": { + "tosLink": "http://whatever.com/help", + "privacyPolicyLink": "http://whatever.com/help", + "helpLink": "http://whatever.com/help" + } + } + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetActiveIdentityProviders", + "out": { + "data": { + "identityProviders": [ + { + "id": "123", + "name": "Hubba bubba", + "type": 10 + } + ] + } + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetPasswordComplexitySettings", + "out": { + "data": { + "settings": { + "minLength": 8, + "requiresUppercase": true, + "requiresLowercase": true, + "requiresNumber": true, + "requiresSymbol": true + } + } + } + } +] diff --git a/login/apps/login-test-integration/core-mock/mocked-services.cfg b/login/apps/login-test-integration/core-mock/mocked-services.cfg new file mode 100644 index 0000000000..6a758ab8c1 --- /dev/null +++ b/login/apps/login-test-integration/core-mock/mocked-services.cfg @@ -0,0 +1,7 @@ +zitadel/user/v2/user_service.proto +zitadel/org/v2/org_service.proto +zitadel/session/v2/session_service.proto +zitadel/settings/v2/settings_service.proto +zitadel/management.proto +zitadel/auth.proto +zitadel/admin.proto \ No newline at end of file diff --git a/login/apps/login-test-integration/cypress.config.ts b/login/apps/login-test-integration/cypress.config.ts new file mode 100644 index 0000000000..080cb31bc6 --- /dev/null +++ b/login/apps/login-test-integration/cypress.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + reporter: "list", + + e2e: { + baseUrl: process.env.LOGIN_BASE_URL || "http://localhost:3000", + specPattern: "integration/**/*.cy.{js,jsx,ts,tsx}", + supportFile: "support/e2e.{js,jsx,ts,tsx}", + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, +}); diff --git a/login/apps/login-test-integration/docker-compose.yaml b/login/apps/login-test-integration/docker-compose.yaml new file mode 100644 index 0000000000..2f09a2253e --- /dev/null +++ b/login/apps/login-test-integration/docker-compose.yaml @@ -0,0 +1,30 @@ +services: + core-mock: + image: "${LOGIN_CORE_MOCK_TAG:-login-core-mock:local}" + container_name: integration-core-mock + ports: + - 22220:22220 + - 22222:22222 + + login: + image: "${LOGIN_TAG:-login:local}" + container_name: integration-login + ports: + - 3001:3001 + environment: + - PORT=3001 + - ZITADEL_API_URL=http://core-mock:22222 + - ZITADEL_SERVICE_USER_TOKEN="yolo" + - EMAIL_VERIFICATION=true + + integration: + image: "${LOGIN_TEST_INTEGRATION_TAG:-login-test-integration:local}" + container_name: integration + environment: + - LOGIN_BASE_URL=http://login:3001/ui/v2/login + - CYPRESS_CORE_MOCK_STUBS_URL=http://core-mock:22220/v1/stubs + depends_on: + login: + condition: service_started + core-mock: + condition: service_started diff --git a/login/apps/login-test-integration/fixtures/example.json b/login/apps/login-test-integration/fixtures/example.json new file mode 100644 index 0000000000..02e4254378 --- /dev/null +++ b/login/apps/login-test-integration/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/login/apps/login-test-integration/integration/invite.cy.ts b/login/apps/login-test-integration/integration/invite.cy.ts new file mode 100644 index 0000000000..a68ff96c36 --- /dev/null +++ b/login/apps/login-test-integration/integration/invite.cy.ts @@ -0,0 +1,110 @@ +import { stub } from "../support/e2e"; + +describe("verify invite", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [], // user with no auth methods was invited + }, + }); + + stub("zitadel.user.v2.UserService", "GetUserByID", { + data: { + user: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: false, + }, + }, + }, + }, + }); + + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowUsernamePassword: true, + }, + }, + }); + }); + + it.only("shows authenticators after successful invite verification", () => { + stub("zitadel.user.v2.UserService", "VerifyInviteCode"); + + cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/authenticator/set"); + }); + + it("shows an error if invite code validation failed", () => { + stub("zitadel.user.v2.UserService", "VerifyInviteCode", { + code: 3, + error: "error validating code", + }); + + // TODO: Avoid uncaught exception in application + cy.once("uncaught:exception", () => false); + cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.contains("Could not verify invite", { timeout: 10_000 }); + }); +}); diff --git a/login/apps/login-test-integration/integration/login.cy.ts b/login/apps/login-test-integration/integration/login.cy.ts new file mode 100644 index 0000000000..917d719cb1 --- /dev/null +++ b/login/apps/login-test-integration/integration/login.cy.ts @@ -0,0 +1,172 @@ +import { stub } from "../support/e2e"; + +describe("login", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowUsernamePassword: true, + }, + }, + }); + }); + describe("password login", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "ListUsers", { + data: { + details: { + totalResult: 1, + }, + result: [ + { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: true, + }, + }, + }, + ], + }, + }); + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [1], // 1 for password authentication + }, + }); + }); + it("should redirect a user with password authentication to /password", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/password"); + }); + describe("with passkey prompt", () => { + beforeEach(() => { + stub("zitadel.session.v2.SessionService", "SetSession", { + data: { + details: { + sequence: 859, + changeDate: "2023-07-04T07:58:20.126Z", + resourceOwner: "220516472055706145", + }, + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + }); + // 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.location("pathname", { timeout: 10_000 }).should("eq", "/password"); + // cy.get('input[type="password"]').focus().type("MyStrongPassword!1"); + // cy.get('button[type="submit"]').click(); + // cy.location("pathname", { timeout: 10_000 }).should( + // "eq", + // "/passkey/set", + // ); + // }); + }); + }); + describe("passkey login", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "ListUsers", { + data: { + details: { + totalResult: 1, + }, + result: [ + { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: true, + }, + }, + }, + ], + }, + }); + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [2], // 2 for passwordless authentication + }, + }); + }); + + it("should redirect a user with passwordless authentication to /passkey", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey"); + }); + }); +}); diff --git a/login/apps/login-test-integration/integration/register-idp.cy.ts b/login/apps/login-test-integration/integration/register-idp.cy.ts new file mode 100644 index 0000000000..73a0c32e00 --- /dev/null +++ b/login/apps/login-test-integration/integration/register-idp.cy.ts @@ -0,0 +1,21 @@ +import { stub } from "../support/e2e"; + +const IDP_URL = "https://example.com/idp/url"; + +describe("register idps", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "StartIdentityProviderIntent", { + data: { + authUrl: IDP_URL, + }, + }); + }); + + it("should redirect the user to the correct url", () => { + cy.visit("/idp"); + cy.get('button[e2e="google"]').click(); + cy.origin(IDP_URL, { args: IDP_URL }, (url) => { + cy.location("href", { timeout: 10_000 }).should("eq", url); + }); + }); +}); diff --git a/login/apps/login-test-integration/integration/register.cy.ts b/login/apps/login-test-integration/integration/register.cy.ts new file mode 100644 index 0000000000..44c53647c1 --- /dev/null +++ b/login/apps/login-test-integration/integration/register.cy.ts @@ -0,0 +1,73 @@ +import { stub } from "../support/e2e"; + +describe("register", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowRegister: true, + allowUsernamePassword: true, + defaultRedirectUri: "", + }, + }, + }); + stub("zitadel.user.v2.UserService", "AddHumanUser", { + data: { + userId: "221394658884845598", + }, + }); + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + }); + + it("should redirect a user who selects passwordless on register to /passkey/set", () => { + cy.visit("/register"); + 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="email-text-input"]').focus().type("john@zitadel.com"); + cy.get('input[type="checkbox"][value="privacypolicy"]').check(); + cy.get('input[type="checkbox"][value="tos"]').check(); + cy.get('button[type="submit"]').click(); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey/set"); + }); +}); diff --git a/login/apps/login-test-integration/integration/verify.cy.ts b/login/apps/login-test-integration/integration/verify.cy.ts new file mode 100644 index 0000000000..db80cea720 --- /dev/null +++ b/login/apps/login-test-integration/integration/verify.cy.ts @@ -0,0 +1,95 @@ +import { stub } from "../support/e2e"; + +describe("verify email", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [1], // set one method such that we know that the user was not invited + }, + }); + + stub("zitadel.user.v2.UserService", "SendEmailCode"); + + stub("zitadel.user.v2.UserService", "GetUserByID", { + data: { + user: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: false, // email is not verified yet + }, + }, + }, + }, + }); + + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + }); + + it("shows an error if email code validation failed", () => { + stub("zitadel.user.v2.UserService", "VerifyEmail", { + code: 3, + error: "error validating code", + }); + // TODO: Avoid uncaught exception in application + cy.once("uncaught:exception", () => false); + cy.visit("/verify?userId=221394658884845598&code=abc"); + cy.contains("Could not verify email", { timeout: 10_000 }); + }); +}); diff --git a/login/apps/login-test-integration/package.json b/login/apps/login-test-integration/package.json new file mode 100644 index 0000000000..f45c5a3413 --- /dev/null +++ b/login/apps/login-test-integration/package.json @@ -0,0 +1,17 @@ +{ + "name": "login-test-integration", + "private": true, + "scripts": { + "test:integration": "dotenv -e ../login/.env.test pnpm exec cypress", + "test:integration:setup": "cd ../.. && make login_test_integration_dev" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "concurrently": "^9.1.2", + "cypress": "^14.3.2", + "env-cmd": "^10.0.0", + "nodemon": "^3.1.9", + "start-server-and-test": "^2.0.11", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login-test-integration/support/e2e.ts b/login/apps/login-test-integration/support/e2e.ts new file mode 100644 index 0000000000..58056c973e --- /dev/null +++ b/login/apps/login-test-integration/support/e2e.ts @@ -0,0 +1,29 @@ +const url = Cypress.env("CORE_MOCK_STUBS_URL") || "http://localhost:22220/v1/stubs"; + +function removeStub(service: string, method: string) { + return cy.request({ + url, + method: "DELETE", + qs: { + service, + method, + }, + }); +} + +export function stub(service: string, method: string, out?: any) { + removeStub(service, method); + return cy.request({ + url, + method: "POST", + body: { + stubs: [ + { + service, + method, + out, + }, + ], + }, + }); +} diff --git a/login/apps/login-test-integration/tsconfig.json b/login/apps/login-test-integration/tsconfig.json new file mode 100644 index 0000000000..18edb199ac --- /dev/null +++ b/login/apps/login-test-integration/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts"] +} diff --git a/login/apps/login-test-integration/turbo.json b/login/apps/login-test-integration/turbo.json new file mode 100644 index 0000000000..2e2c7cfb42 --- /dev/null +++ b/login/apps/login-test-integration/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "test:integration:setup": { + "interactive": true, + "cache": false, + "persistent": true + } + } +} diff --git a/login/apps/login/.env.test b/login/apps/login/.env.test new file mode 100644 index 0000000000..ee70003348 --- /dev/null +++ b/login/apps/login/.env.test @@ -0,0 +1,5 @@ +NEXT_PUBLIC_BASE_PATH="" +ZITADEL_API_URL=http://localhost:22222 +ZITADEL_SERVICE_USER_TOKEN="yolo" +EMAIL_VERIFICATION=true +DEBUG=true diff --git a/login/apps/login/.eslintrc.cjs b/login/apps/login/.eslintrc.cjs new file mode 100755 index 0000000000..f5383dd47a --- /dev/null +++ b/login/apps/login/.eslintrc.cjs @@ -0,0 +1,12 @@ +module.exports = { + extends: ["next/core-web-vitals"], + ignorePatterns: ["external/**/*.ts"], + rules: { + "@next/next/no-html-link-for-pages": "off", + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/login/apps/login/.gitignore b/login/apps/login/.gitignore new file mode 100644 index 0000000000..caf3c1ec81 --- /dev/null +++ b/login/apps/login/.gitignore @@ -0,0 +1,3 @@ +custom-config.js +.env*.local +standalone diff --git a/login/apps/login/.prettierignore b/login/apps/login/.prettierignore new file mode 100644 index 0000000000..dbcbbd11d1 --- /dev/null +++ b/login/apps/login/.prettierignore @@ -0,0 +1,2 @@ +.next +/external \ No newline at end of file diff --git a/login/apps/login/constants/csp.js b/login/apps/login/constants/csp.js new file mode 100644 index 0000000000..5cc1e254f3 --- /dev/null +++ b/login/apps/login/constants/csp.js @@ -0,0 +1,2 @@ +export const DEFAULT_CSP = + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com;"; diff --git a/login/apps/login/locales/de.json b/login/apps/login/locales/de.json new file mode 100644 index 0000000000..75897a628e --- /dev/null +++ b/login/apps/login/locales/de.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Zurück" + }, + "accounts": { + "title": "Konten", + "description": "Wählen Sie das Konto aus, das Sie verwenden möchten.", + "addAnother": "Ein weiteres Konto hinzufügen", + "noResults": "Keine Konten gefunden" + }, + "logout": { + "title": "Logout", + "description": "Wählen Sie den Account aus, das Sie entfernen möchten", + "noResults": "Keine Konten gefunden", + "clear": "Session beenden", + "verifiedAt": "Zuletzt aktiv: {time}", + "success": { + "title": "Logout erfolgreich", + "description": "Sie haben sich erfolgreich abgemeldet." + } + }, + "loginname": { + "title": "Willkommen zurück!", + "description": "Geben Sie Ihre Anmeldedaten ein.", + "register": "Neuen Benutzer registrieren", + "submit": "Weiter" + }, + "password": { + "verify": { + "title": "Passwort", + "description": "Geben Sie Ihr Passwort ein.", + "resetPassword": "Passwort zurücksetzen", + "submit": "Weiter" + }, + "set": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.", + "noCodeReceived": "Keinen Code erhalten?", + "resend": "Erneut senden", + "submit": "Weiter" + }, + "change": { + "title": "Passwort ändern", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "idp": { + "title": "Mit SSO anmelden", + "description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden", + "orSignInWith": "oder melden Sie sich an mit", + "signInWithApple": "Mit Apple anmelden", + "signInWithGoogle": "Mit Google anmelden", + "signInWithAzureAD": "Mit AzureAD anmelden", + "signInWithGithub": "Mit GitHub anmelden", + "signInWithGitlab": "Mit GitLab anmelden", + "loginSuccess": { + "title": "Anmeldung erfolgreich", + "description": "Sie haben sich erfolgreich angemeldet!" + }, + "linkingSuccess": { + "title": "Konto verknüpft", + "description": "Sie haben Ihr Konto erfolgreich verknüpft!" + }, + "registerSuccess": { + "title": "Registrierung erfolgreich", + "description": "Sie haben sich erfolgreich registriert!" + }, + "loginError": { + "title": "Anmeldung fehlgeschlagen", + "description": "Beim Anmelden ist ein Fehler aufgetreten." + }, + "linkingError": { + "title": "Konto-Verknüpfung fehlgeschlagen", + "description": "Beim Verknüpfen Ihres Kontos ist ein Fehler aufgetreten." + }, + "completeRegister": { + "title": "Registrierung abschließen", + "description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen." + } + }, + "ldap": { + "title": "LDAP Login", + "description": "Geben Sie Ihre LDAP-Anmeldedaten ein.", + "username": "Benutzername", + "password": "Passwort", + "submit": "Weiter" + }, + "mfa": { + "verify": { + "title": "Bestätigen Sie Ihre Identität", + "description": "Wählen Sie einen der folgenden Faktoren.", + "noResults": "Keine zweiten Faktoren verfügbar, um sie einzurichten." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Wählen Sie einen der folgenden zweiten Faktoren.", + "skip": "Überspringen" + } + }, + "otp": { + "verify": { + "title": "2-Faktor bestätigen", + "totpDescription": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein.", + "smsDescription": "Geben Sie den Code ein, den Sie per SMS erhalten haben.", + "emailDescription": "Geben Sie den Code ein, den Sie per E-Mail erhalten haben.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + }, + "set": { + "title": "2-Faktor einrichten", + "totpDescription": "Scannen Sie den QR-Code mit Ihrer Authentifizierungs-App.", + "smsDescription": "Geben Sie Ihre Telefonnummer ein, um einen Code per SMS zu erhalten.", + "emailDescription": "Geben Sie Ihre E-Mail-Adresse ein, um einen Code per E-Mail zu erhalten.", + "totpRegisterDescription": "Scannen Sie den QR-Code oder navigieren Sie manuell zur URL.", + "submit": "Weiter" + } + }, + "passkey": { + "verify": { + "title": "Mit einem Passkey authentifizieren", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "usePassword": "Passwort verwenden", + "submit": "Weiter" + }, + "set": { + "title": "Passkey einrichten", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "info": { + "description": "Ein Passkey ist eine Authentifizierungsmethode auf einem Gerät wie Ihr Fingerabdruck, Apple FaceID oder ähnliches.", + "link": "Passwortlose Authentifizierung" + }, + "skip": "Überspringen", + "submit": "Weiter" + } + }, + "u2f": { + "verify": { + "title": "2-Faktor bestätigen", + "description": "Bestätigen Sie Ihr Konto mit Ihrem Gerät." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Richten Sie ein Gerät als zweiten Faktor ein.", + "submit": "Weiter" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registrierung deaktiviert", + "description": "Die Registrierung ist deaktiviert. Bitte wenden Sie sich an den Administrator." + }, + "missingdata": { + "title": "Registrierung fehlgeschlagen", + "description": "Einige Daten fehlen. Bitte überprüfen Sie Ihre Eingaben." + }, + "title": "Registrieren", + "description": "Erstellen Sie Ihr ZITADEL-Konto.", + "noMethodAvailableWarning": "Keine Authentifizierungsmethode verfügbar. Bitte wenden Sie sich an den Administrator.", + "selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten", + "agreeTo": "Um sich zu registrieren, müssen Sie den Nutzungsbedingungen zustimmen", + "termsOfService": "Nutzungsbedingungen", + "privacyPolicy": "Datenschutzrichtlinie", + "submit": "Weiter", + "orUseIDP": "oder verwenden Sie einen Identitätsanbieter", + "password": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "invite": { + "title": "Benutzer einladen", + "description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.", + "info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.", + "notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.", + "submit": "Einladen", + "success": { + "title": "Einladung erfolgreich", + "description": "Der Benutzer wurde erfolgreich eingeladen.", + "verified": "Der Benutzer wurde eingeladen und hat seine E-Mail bereits verifiziert.", + "notVerifiedYet": "Der Benutzer wurde eingeladen. Er erhält eine E-Mail mit weiteren Anweisungen.", + "submit": "Weiteren Benutzer einladen" + } + }, + "signedin": { + "title": "Willkommen {user}!", + "description": "Sie sind angemeldet.", + "continue": "Weiter", + "error": { + "title": "Fehler", + "description": "Ein Fehler ist aufgetreten." + } + }, + "verify": { + "userIdMissing": "Keine Benutzer-ID angegeben!", + "successTitle": "Benutzer verifiziert", + "successDescription": "Der Benutzer wurde erfolgreich verifiziert.", + "setupAuthenticator": "Authentifikator einrichten", + "verify": { + "title": "Benutzer verifizieren", + "description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "codeSent": "Ein Code wurde gerade an Ihre E-Mail-Adresse gesendet.", + "submit": "Weiter" + } + }, + "authenticator": { + "title": "Authentifizierungsmethode auswählen", + "description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.", + "noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar", + "allSetup": "Sie haben bereits einen Authentifikator eingerichtet!", + "linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter" + }, + "device": { + "usercode": { + "title": "Gerätecode", + "description": "Geben Sie den Code ein.", + "submit": "Weiter" + }, + "request": { + "title": "{appName} möchte eine Verbindung herstellen:", + "disclaimer": "{appName} hat Zugriff auf:", + "description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", + "submit": "Zulassen", + "deny": "Ablehnen" + }, + "scope": { + "openid": "Überprüfen Ihrer Identität.", + "email": "Zugriff auf Ihre E-Mail-Adresse.", + "profile": "Zugriff auf Ihre vollständigen Profilinformationen.", + "offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto." + } + }, + "error": { + "noUserCode": "Kein Benutzercode angegeben!", + "noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.", + "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", + "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "tryagain": "Erneut versuchen" + } +} diff --git a/login/apps/login/locales/en.json b/login/apps/login/locales/en.json new file mode 100644 index 0000000000..9f95403063 --- /dev/null +++ b/login/apps/login/locales/en.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Back" + }, + "accounts": { + "title": "Accounts", + "description": "Select the account you want to use.", + "addAnother": "Add another account", + "noResults": "No accounts found" + }, + "logout": { + "title": "Logout", + "description": "Click an account to end the session", + "noResults": "No accounts found", + "clear": "End Session", + "verifiedAt": "Last active: {time}", + "success": { + "title": "Logout successful", + "description": "You have successfully logged out." + } + }, + "loginname": { + "title": "Welcome back!", + "description": "Enter your login data.", + "register": "Register new user", + "submit": "Continue" + }, + "password": { + "verify": { + "title": "Password", + "description": "Enter your password.", + "resetPassword": "Reset Password", + "submit": "Continue" + }, + "set": { + "title": "Set Password", + "description": "Set the password for your account", + "codeSent": "A code has been sent to your email address.", + "noCodeReceived": "Didn't receive a code?", + "resend": "Resend code", + "submit": "Continue" + }, + "change": { + "title": "Change Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "idp": { + "title": "Sign in with SSO", + "description": "Select one of the following providers to sign in", + "orSignInWith": "or sign in with", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google", + "signInWithAzureAD": "Sign in with AzureAD", + "signInWithGithub": "Sign in with GitHub", + "signInWithGitlab": "Sign in with GitLab", + "loginSuccess": { + "title": "Login successful", + "description": "You have successfully been loggedIn!" + }, + "linkingSuccess": { + "title": "Account linked", + "description": "You have successfully linked your account!" + }, + "registerSuccess": { + "title": "Registration successful", + "description": "You have successfully registered!" + }, + "loginError": { + "title": "Login failed", + "description": "An error occurred while trying to login." + }, + "linkingError": { + "title": "Account linking failed", + "description": "An error occurred while trying to link your account." + }, + "completeRegister": { + "title": "Complete your data", + "description": "You need to complete your registration by providing your email address and name." + } + }, + "ldap": { + "title": "LDAP Login", + "description": "Enter your LDAP credentials.", + "username": "Username", + "password": "Password", + "submit": "Continue" + }, + "mfa": { + "verify": { + "title": "Verify your identity", + "description": "Choose one of the following factors.", + "noResults": "No second factors available to setup." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Choose one of the following second factors.", + "skip": "Skip" + } + }, + "otp": { + "verify": { + "title": "Verify 2-Factor", + "totpDescription": "Enter the code from your authenticator app.", + "smsDescription": "Enter the code you received via SMS.", + "emailDescription": "Enter the code you received via email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + }, + "set": { + "title": "Set up 2-Factor", + "totpDescription": "Scan the QR code with your authenticator app.", + "smsDescription": "Enter your phone number to receive a code via SMS.", + "emailDescription": "Enter your email address to receive a code via email.", + "totpRegisterDescription": "Scan the QR Code or navigate to the URL manually.", + "submit": "Continue" + } + }, + "passkey": { + "verify": { + "title": "Authenticate with a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "usePassword": "Use password", + "submit": "Continue" + }, + "set": { + "title": "Setup a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "info": { + "description": "A passkey is an authentication method on a device like your fingerprint, Apple FaceID or similar. ", + "link": "Passwordless Authentication" + }, + "skip": "Skip", + "submit": "Continue" + } + }, + "u2f": { + "verify": { + "title": "Verify 2-Factor", + "description": "Verify your account with your device." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Set up a device as a second factor.", + "submit": "Continue" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "The registration is disabled. Please contact your administrator." + }, + "missingdata": { + "title": "Missing data", + "description": "Provide email, first and last name to register." + }, + "title": "Register", + "description": "Create your ZITADEL account.", + "noMethodAvailableWarning": "No authentication method available. Please contact your administrator.", + "selectMethod": "Select the method you would like to authenticate", + "agreeTo": "To register you must agree to the terms and conditions", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "submit": "Continue", + "orUseIDP": "or use an Identity Provider", + "password": { + "title": "Set Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "invite": { + "title": "Invite User", + "description": "Provide the email address and the name of the user you want to invite.", + "info": "The user will receive an email with further instructions.", + "notAllowed": "Your settings do not allow you to invite users.", + "submit": "Continue", + "success": { + "title": "User invited", + "description": "The email has successfully been sent.", + "verified": "The user has been invited and has already verified his email.", + "notVerifiedYet": "The user has been invited. They will receive an email with further instructions.", + "submit": "Invite another user" + } + }, + "signedin": { + "title": "Welcome {user}!", + "description": "You are signed in.", + "continue": "Continue", + "error": { + "title": "Error", + "description": "An error occurred while trying to sign in." + } + }, + "verify": { + "userIdMissing": "No userId provided!", + "successTitle": "User verified", + "successDescription": "The user has been verified successfully.", + "setupAuthenticator": "Setup authenticator", + "verify": { + "title": "Verify user", + "description": "Enter the Code provided in the verification email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "codeSent": "A code has just been sent to your email address.", + "submit": "Continue" + } + }, + "authenticator": { + "title": "Choose authentication method", + "description": "Select the method you would like to authenticate", + "noMethodsAvailable": "No authentication methods available", + "allSetup": "You have already setup an authenticator!", + "linkWithIDP": "or link with an Identity Provider" + }, + "device": { + "usercode": { + "title": "Device code", + "description": "Enter the code displayed on your app or device.", + "submit": "Continue" + }, + "request": { + "title": "{appName} would like to connect", + "description": "{appName} will have access to:", + "disclaimer": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "submit": "Allow", + "deny": "Deny" + }, + "scope": { + "openid": "Verify your identity.", + "email": "View your email address.", + "profile": "View your full profile information.", + "offline_access": "Allow offline access to your account." + } + }, + "error": { + "noUserCode": "No user code provided!", + "noDeviceRequest": "No device request found.", + "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", + "sessionExpired": "Your current session has expired. Please login again.", + "failedLoading": "Failed to load data. Please try again.", + "tryagain": "Try Again" + } +} diff --git a/login/apps/login/locales/es.json b/login/apps/login/locales/es.json new file mode 100644 index 0000000000..fe88bb94c6 --- /dev/null +++ b/login/apps/login/locales/es.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Atrás" + }, + "accounts": { + "title": "Cuentas", + "description": "Selecciona la cuenta que deseas usar.", + "addAnother": "Agregar otra cuenta", + "noResults": "No se encontraron cuentas" + }, + "logout": { + "title": "Cerrar sesión", + "description": "Selecciona la cuenta que deseas eliminar", + "noResults": "No se encontraron cuentas", + "clear": "Eliminar sesión", + "verifiedAt": "Última actividad: {time}", + "success": { + "title": "Cierre de sesión exitoso", + "description": "Has cerrado sesión correctamente." + } + }, + "loginname": { + "title": "¡Bienvenido de nuevo!", + "description": "Introduce tus datos de acceso.", + "register": "Registrar nuevo usuario", + "submit": "Continuar" + }, + "password": { + "verify": { + "title": "Contraseña", + "description": "Introduce tu contraseña.", + "resetPassword": "Restablecer contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "codeSent": "Se ha enviado un código a su correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resend": "Reenviar código", + "submit": "Continuar" + }, + "change": { + "title": "Cambiar Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "idp": { + "title": "Iniciar sesión con SSO", + "description": "Selecciona uno de los siguientes proveedores para iniciar sesión", + "orSignInWith": "o iniciar sesión con", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google", + "signInWithAzureAD": "Iniciar sesión con AzureAD", + "signInWithGithub": "Iniciar sesión con GitHub", + "signInWithGitlab": "Iniciar sesión con GitLab", + "loginSuccess": { + "title": "Inicio de sesión exitoso", + "description": "¡Has iniciado sesión con éxito!" + }, + "linkingSuccess": { + "title": "Cuenta vinculada", + "description": "¡Has vinculado tu cuenta con éxito!" + }, + "registerSuccess": { + "title": "Registro exitoso", + "description": "¡Te has registrado con éxito!" + }, + "loginError": { + "title": "Error de inicio de sesión", + "description": "Ocurrió un error al intentar iniciar sesión." + }, + "linkingError": { + "title": "Error al vincular la cuenta", + "description": "Ocurrió un error al intentar vincular tu cuenta." + }, + "completeRegister": { + "title": "Completar registro", + "description": "Para completar el registro, debes establecer una contraseña." + } + }, + "ldap": { + "title": "Iniciar sesión con LDAP", + "description": "Introduce tus credenciales LDAP.", + "username": "Nombre de usuario", + "password": "Contraseña", + "submit": "Continuar" + }, + "mfa": { + "verify": { + "title": "Verifica tu identidad", + "description": "Elige uno de los siguientes factores.", + "noResults": "No hay factores secundarios disponibles para configurar." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Elige uno de los siguientes factores secundarios.", + "skip": "Omitir" + } + }, + "otp": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "totpDescription": "Introduce el código de tu aplicación de autenticación.", + "smsDescription": "Introduce el código que recibiste por SMS.", + "emailDescription": "Introduce el código que recibiste por correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "totpDescription": "Escanea el código QR con tu aplicación de autenticación.", + "smsDescription": "Introduce tu número de teléfono para recibir un código por SMS.", + "emailDescription": "Introduce tu dirección de correo electrónico para recibir un código por correo electrónico.", + "totpRegisterDescription": "Escanea el código QR o navega manualmente a la URL.", + "submit": "Continuar" + } + }, + "passkey": { + "verify": { + "title": "Autenticar con una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "usePassword": "Usar contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Configurar una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "info": { + "description": "Una clave de acceso es un método de autenticación en un dispositivo como tu huella digital, Apple FaceID o similar.", + "link": "Autenticación sin contraseña" + }, + "skip": "Omitir", + "submit": "Continuar" + } + }, + "u2f": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "description": "Verifica tu cuenta con tu dispositivo." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Configura un dispositivo como segundo factor.", + "submit": "Continuar" + } + }, + "register": { + "methods": { + "passkey": "Clave de acceso", + "password": "Contraseña" + }, + "disabled": { + "title": "Registro deshabilitado", + "description": "Registrarse está deshabilitado en este momento." + }, + "missingdata": { + "title": "Datos faltantes", + "description": "No se proporcionaron datos suficientes para el registro." + }, + "title": "Registrarse", + "description": "Crea tu cuenta ZITADEL.", + "noMethodAvailableWarning": "No hay métodos de autenticación disponibles. Por favor, contacta a tu administrador.", + "selectMethod": "Selecciona el método con el que deseas autenticarte", + "agreeTo": "Para registrarte debes aceptar los términos y condiciones", + "termsOfService": "Términos de Servicio", + "privacyPolicy": "Política de Privacidad", + "submit": "Continuar", + "orUseIDP": "o usa un Proveedor de Identidad", + "password": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "invite": { + "title": "Invitar usuario", + "description": "Introduce el correo electrónico del usuario que deseas invitar.", + "info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.", + "notAllowed": "No tienes permiso para invitar usuarios.", + "submit": "Invitar usuario", + "success": { + "title": "¡Usuario invitado!", + "description": "El usuario ha sido invitado.", + "verified": "El usuario ha sido invitado y ya ha verificado su correo electrónico.", + "notVerifiedYet": "El usuario ha sido invitado. Recibirá un correo electrónico con más instrucciones.", + "submit": "Invitar a otro usuario" + } + }, + "signedin": { + "title": "¡Bienvenido {user}!", + "description": "Has iniciado sesión.", + "continue": "Continuar", + "error": { + "title": "Error", + "description": "Ocurrió un error al iniciar sesión." + } + }, + "verify": { + "userIdMissing": "¡No se proporcionó userId!", + "successTitle": "Usuario verificado", + "successDescription": "El usuario ha sido verificado con éxito.", + "setupAuthenticator": "Configurar autenticador", + "verify": { + "title": "Verificar usuario", + "description": "Introduce el código proporcionado en el correo electrónico de verificación.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "codeSent": "Se ha enviado un código a tu dirección de correo electrónico.", + "submit": "Continuar" + } + }, + "authenticator": { + "title": "Seleccionar método de autenticación", + "description": "Selecciona el método con el que deseas autenticarte", + "noMethodsAvailable": "No hay métodos de autenticación disponibles", + "allSetup": "¡Ya has configurado un autenticador!", + "linkWithIDP": "o vincúlalo con un proveedor de identidad" + }, + "device": { + "usercode": { + "title": "Código del dispositivo", + "description": "Introduce el código.", + "submit": "Continuar" + }, + "request": { + "title": "{appName} desea conectarse:", + "description": "{appName} tendrá acceso a:", + "disclaimer": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", + "submit": "Permitir", + "deny": "Denegar" + }, + "scope": { + "openid": "Verifica tu identidad.", + "email": "Accede a tu dirección de correo electrónico.", + "profile": "Accede a la información completa de tu perfil.", + "offline_access": "Permitir acceso sin conexión a tu cuenta." + } + }, + "error": { + "noUserCode": "¡No se proporcionó código de usuario!", + "noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.", + "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", + "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", + "failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.", + "tryagain": "Intentar de nuevo" + } +} diff --git a/login/apps/login/locales/it.json b/login/apps/login/locales/it.json new file mode 100644 index 0000000000..1229a1a4c0 --- /dev/null +++ b/login/apps/login/locales/it.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Indietro" + }, + "accounts": { + "title": "Account", + "description": "Seleziona l'account che desideri utilizzare.", + "addAnother": "Aggiungi un altro account", + "noResults": "Nessun account trovato" + }, + "logout": { + "title": "Esci", + "description": "Seleziona l'account che desideri uscire", + "noResults": "Nessun account trovato", + "clear": "Elimina sessione", + "verifiedAt": "Ultima attività: {time}", + "success": { + "title": "Uscita riuscita", + "description": "Hai effettuato l'uscita con successo." + } + }, + "loginname": { + "title": "Bentornato!", + "description": "Inserisci i tuoi dati di accesso.", + "register": "Registrati come nuovo utente", + "submit": "Continua" + }, + "password": { + "verify": { + "title": "Password", + "description": "Inserisci la tua password.", + "resetPassword": "Reimposta Password", + "submit": "Continua" + }, + "set": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "codeSent": "Un codice è stato inviato al tuo indirizzo email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resend": "Invia di nuovo", + "submit": "Continua" + }, + "change": { + "title": "Cambia Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "idp": { + "title": "Accedi con SSO", + "description": "Seleziona uno dei seguenti provider per accedere", + "orSignInWith": "o accedi con", + "signInWithApple": "Accedi con Apple", + "signInWithGoogle": "Accedi con Google", + "signInWithAzureAD": "Accedi con AzureAD", + "signInWithGithub": "Accedi con GitHub", + "signInWithGitlab": "Accedi con GitLab", + "loginSuccess": { + "title": "Accesso riuscito", + "description": "Accesso effettuato con successo!" + }, + "linkingSuccess": { + "title": "Account collegato", + "description": "Hai collegato con successo il tuo account!" + }, + "registerSuccess": { + "title": "Registrazione riuscita", + "description": "Registrazione effettuata con successo!" + }, + "loginError": { + "title": "Accesso fallito", + "description": "Si è verificato un errore durante il tentativo di accesso." + }, + "linkingError": { + "title": "Collegamento account fallito", + "description": "Si è verificato un errore durante il tentativo di collegare il tuo account." + }, + "completeRegister": { + "title": "Completa la registrazione", + "description": "Completa la registrazione del tuo account." + } + }, + "ldap": { + "title": "Accedi con LDAP", + "description": "Inserisci le tue credenziali LDAP.", + "username": "Nome utente", + "password": "Password", + "submit": "Continua" + }, + "mfa": { + "verify": { + "title": "Verifica la tua identità", + "description": "Scegli uno dei seguenti fattori.", + "noResults": "Nessun secondo fattore disponibile per la configurazione." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Scegli uno dei seguenti secondi fattori.", + "skip": "Salta" + } + }, + "otp": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "totpDescription": "Inserisci il codice dalla tua app di autenticazione.", + "smsDescription": "Inserisci il codice ricevuto via SMS.", + "emailDescription": "Inserisci il codice ricevuto via email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "totpDescription": "Scansiona il codice QR con la tua app di autenticazione.", + "smsDescription": "Inserisci il tuo numero di telefono per ricevere un codice via SMS.", + "emailDescription": "Inserisci il tuo indirizzo email per ricevere un codice via email.", + "totpRegisterDescription": "Scansiona il codice QR o naviga manualmente all'URL.", + "submit": "Continua" + } + }, + "passkey": { + "verify": { + "title": "Autenticati con una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "usePassword": "Usa password", + "submit": "Continua" + }, + "set": { + "title": "Configura una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "info": { + "description": "Una passkey è un metodo di autenticazione su un dispositivo come la tua impronta digitale, Apple FaceID o simili.", + "link": "Autenticazione senza password" + }, + "skip": "Salta", + "submit": "Continua" + } + }, + "u2f": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "description": "Verifica il tuo account con il tuo dispositivo." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Configura un dispositivo come secondo fattore.", + "submit": "Continua" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "Registrazione disabilitata. Contatta l'amministratore di sistema per assistenza." + }, + "missingdata": { + "title": "Registrazione", + "description": "Inserisci i tuoi dati per registrarti." + }, + "title": "Registrati", + "description": "Crea il tuo account ZITADEL.", + "noMethodAvailableWarning": "Nessun metodo di autenticazione disponibile. Contatta l'amministratore di sistema per assistenza.", + "selectMethod": "Seleziona il metodo con cui desideri autenticarti", + "agreeTo": "Per registrarti devi accettare i termini e le condizioni", + "termsOfService": "Termini di Servizio", + "privacyPolicy": "Informativa sulla Privacy", + "submit": "Continua", + "orUseIDP": "o usa un Identity Provider", + "password": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "invite": { + "title": "Invita Utente", + "description": "Inserisci l'indirizzo email dell'utente che desideri invitare.", + "info": "L'utente riceverà un'email con ulteriori istruzioni.", + "notAllowed": "Non hai i permessi per invitare un utente.", + "submit": "Invita Utente", + "success": { + "title": "Invito inviato", + "description": "L'utente è stato invitato con successo.", + "verified": "L'utente è stato invitato e ha già verificato la sua email.", + "notVerifiedYet": "L'utente è stato invitato. Riceverà un'email con ulteriori istruzioni.", + "submit": "Invita un altro utente" + } + }, + "signedin": { + "title": "Benvenuto {user}!", + "description": "Sei connesso.", + "continue": "Continua", + "error": { + "title": "Errore", + "description": "Si è verificato un errore durante il tentativo di accesso." + } + }, + "verify": { + "userIdMissing": "Nessun userId fornito!", + "successTitle": "Utente verificato", + "successDescription": "L'utente è stato verificato con successo.", + "setupAuthenticator": "Configura autenticatore", + "verify": { + "title": "Verifica utente", + "description": "Inserisci il codice fornito nell'email di verifica.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "codeSent": "Un codice è stato appena inviato al tuo indirizzo email.", + "submit": "Continua" + } + }, + "authenticator": { + "title": "Seleziona metodo di autenticazione", + "description": "Seleziona il metodo con cui desideri autenticarti", + "noMethodsAvailable": "Nessun metodo di autenticazione disponibile", + "allSetup": "Hai già configurato un autenticatore!", + "linkWithIDP": "o collega con un Identity Provider" + }, + "device": { + "usercode": { + "title": "Codice dispositivo", + "description": "Inserisci il codice.", + "submit": "Continua" + }, + "request": { + "title": "{appName} desidera connettersi:", + "description": "{appName} avrà accesso a:", + "disclaimer": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", + "submit": "Consenti", + "deny": "Nega" + }, + "scope": { + "openid": "Verifica la tua identità.", + "email": "Accedi al tuo indirizzo email.", + "profile": "Accedi alle informazioni complete del tuo profilo.", + "offline_access": "Consenti l'accesso offline al tuo account." + } + }, + "error": { + "noUserCode": "Nessun codice utente fornito!", + "noDeviceRequest": "Nessuna richiesta di dispositivo trovata.", + "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", + "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", + "failedLoading": "Impossibile caricare i dati. Riprova.", + "tryagain": "Riprova" + } +} diff --git a/login/apps/login/locales/pl.json b/login/apps/login/locales/pl.json new file mode 100644 index 0000000000..9fea6a19fa --- /dev/null +++ b/login/apps/login/locales/pl.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Powrót" + }, + "accounts": { + "title": "Konta", + "description": "Wybierz konto, którego chcesz użyć.", + "addAnother": "Dodaj kolejne konto", + "noResults": "Nie znaleziono kont" + }, + "logout": { + "title": "Wyloguj się", + "description": "Wybierz konto, które chcesz usunąć", + "noResults": "Nie znaleziono kont", + "clear": "Usuń sesję", + "verifiedAt": "Ostatnia aktywność: {time}", + "success": { + "title": "Wylogowanie udane", + "description": "Pomyślnie się wylogowałeś." + } + }, + "loginname": { + "title": "Witamy ponownie!", + "description": "Wprowadź dane logowania.", + "register": "Zarejestruj nowego użytkownika", + "submit": "Kontynuuj" + }, + "password": { + "verify": { + "title": "Hasło", + "description": "Wprowadź swoje hasło.", + "resetPassword": "Zresetuj hasło", + "submit": "Kontynuuj" + }, + "set": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "codeSent": "Kod został wysłany na twój adres e-mail.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resend": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "change": { + "title": "Zmień hasło", + "description": "Ustaw nowe hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "idp": { + "title": "Zaloguj się za pomocą SSO", + "description": "Wybierz jednego z poniższych dostawców, aby się zalogować", + "orSignInWith": "lub zaloguj się przez", + "signInWithApple": "Zaloguj się przez Apple", + "signInWithGoogle": "Zaloguj się przez Google", + "signInWithAzureAD": "Zaloguj się przez AzureAD", + "signInWithGithub": "Zaloguj się przez GitHub", + "signInWithGitlab": "Zaloguj się przez GitLab", + "loginSuccess": { + "title": "Logowanie udane", + "description": "Zostałeś pomyślnie zalogowany!" + }, + "linkingSuccess": { + "title": "Konto powiązane", + "description": "Pomyślnie powiązałeś swoje konto!" + }, + "registerSuccess": { + "title": "Rejestracja udana", + "description": "Pomyślnie się zarejestrowałeś!" + }, + "loginError": { + "title": "Logowanie nieudane", + "description": "Wystąpił błąd podczas próby logowania." + }, + "linkingError": { + "title": "Powiązanie konta nie powiodło się", + "description": "Wystąpił błąd podczas próby powiązania konta." + }, + "completeRegister": { + "title": "Ukończ rejestrację", + "description": "Ukończ rejestrację swojego konta." + } + }, + "ldap": { + "title": "Zaloguj się przez LDAP", + "description": "Wprowadź swoje dane logowania LDAP.", + "username": "Nazwa użytkownika", + "password": "Hasło", + "submit": "Kontynuuj" + }, + "mfa": { + "verify": { + "title": "Zweryfikuj swoją tożsamość", + "description": "Wybierz jeden z poniższych sposobów weryfikacji.", + "noResults": "Nie znaleziono dostępnych metod uwierzytelniania dwuskładnikowego." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Wybierz jedną z poniższych metod drugiego czynnika.", + "skip": "Pomiń" + } + }, + "otp": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Wprowadź kod z aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź kod otrzymany SMS-em.", + "emailDescription": "Wprowadź kod otrzymany e-mailem.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź swój numer telefonu, aby otrzymać kod SMS-em.", + "emailDescription": "Wprowadź swój adres e-mail, aby otrzymać kod e-mailem.", + "totpRegisterDescription": "Zeskanuj kod QR lub otwórz adres URL ręcznie.", + "submit": "Kontynuuj" + } + }, + "passkey": { + "verify": { + "title": "Uwierzytelnij się za pomocą klucza dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "usePassword": "Użyj hasła", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj klucz dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "info": { + "description": "Klucz dostępu to metoda uwierzytelniania na urządzeniu, wykorzystująca np. odcisk palca, Apple FaceID lub podobne rozwiązania.", + "link": "Uwierzytelnianie bez hasła" + }, + "skip": "Pomiń", + "submit": "Kontynuuj" + } + }, + "u2f": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "description": "Zweryfikuj swoje konto za pomocą urządzenia." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Skonfiguruj urządzenie jako dodatkowy czynnik uwierzytelniania.", + "submit": "Kontynuuj" + } + }, + "register": { + "methods": { + "passkey": "Klucz dostępu", + "password": "Hasło" + }, + "disabled": { + "title": "Rejestracja wyłączona", + "description": "Rejestracja jest wyłączona. Skontaktuj się z administratorem." + }, + "missingdata": { + "title": "Brak danych", + "description": "Podaj e-mail, imię i nazwisko, aby się zarejestrować." + }, + "title": "Rejestracja", + "description": "Utwórz konto ZITADEL.", + "noMethodAvailableWarning": "Brak dostępnych metod uwierzytelniania. Skontaktuj się z administratorem.", + "selectMethod": "Wybierz metodę uwierzytelniania, której chcesz użyć", + "agreeTo": "Aby się zarejestrować, musisz zaakceptować warunki korzystania", + "termsOfService": "Regulamin", + "privacyPolicy": "Polityka prywatności", + "submit": "Kontynuuj", + "orUseIDP": "lub użyj dostawcy tożsamości", + "password": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "invite": { + "title": "Zaproś użytkownika", + "description": "Podaj adres e-mail oraz imię i nazwisko użytkownika, którego chcesz zaprosić.", + "info": "Użytkownik otrzyma e-mail z dalszymi instrukcjami.", + "notAllowed": "Twoje ustawienia nie pozwalają na zapraszanie użytkowników.", + "submit": "Kontynuuj", + "success": { + "title": "Użytkownik zaproszony", + "description": "E-mail został pomyślnie wysłany.", + "verified": "Użytkownik został zaproszony i już zweryfikował swój e-mail.", + "notVerifiedYet": "Użytkownik został zaproszony. Otrzyma e-mail z dalszymi instrukcjami.", + "submit": "Zaproś kolejnego użytkownika" + } + }, + "signedin": { + "title": "Witaj {user}!", + "description": "Jesteś zalogowany.", + "continue": "Kontynuuj", + "error": { + "title": "Błąd", + "description": "Nie można załadować danych. Sprawdź połączenie z internetem lub spróbuj ponownie później." + } + }, + "verify": { + "userIdMissing": "Nie podano identyfikatora użytkownika!", + "successTitle": "Weryfikacja zakończona", + "successDescription": "Użytkownik został pomyślnie zweryfikowany.", + "setupAuthenticator": "Skonfiguruj uwierzytelnianie", + "verify": { + "title": "Zweryfikuj użytkownika", + "description": "Wprowadź kod z wiadomości weryfikacyjnej.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "codeSent": "Kod został właśnie wysłany na twój adres e-mail.", + "submit": "Kontynuuj" + } + }, + "authenticator": { + "title": "Wybierz metodę uwierzytelniania", + "description": "Wybierz metodę, której chcesz użyć do uwierzytelnienia.", + "noMethodsAvailable": "Brak dostępnych metod uwierzytelniania", + "allSetup": "Już skonfigurowałeś metodę uwierzytelniania!", + "linkWithIDP": "lub połącz z dostawcą tożsamości" + }, + "device": { + "usercode": { + "title": "Kod urządzenia", + "description": "Wprowadź kod.", + "submit": "Kontynuuj" + }, + "request": { + "title": "{appName} chce się połączyć:", + "description": "{appName} będzie miało dostęp do:", + "disclaimer": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.", + "submit": "Zezwól", + "deny": "Odmów" + }, + "scope": { + "openid": "Zweryfikuj swoją tożsamość.", + "email": "Uzyskaj dostęp do swojego adresu e-mail.", + "profile": "Uzyskaj dostęp do pełnych informacji o swoim profilu.", + "offline_access": "Zezwól na dostęp offline do swojego konta." + } + }, + "error": { + "noUserCode": "Nie podano kodu użytkownika!", + "noDeviceRequest": "Nie znaleziono żądania urządzenia.", + "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", + "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", + "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", + "tryagain": "Spróbuj ponownie" + } +} diff --git a/login/apps/login/locales/ru.json b/login/apps/login/locales/ru.json new file mode 100644 index 0000000000..e745f1ae59 --- /dev/null +++ b/login/apps/login/locales/ru.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Назад" + }, + "accounts": { + "title": "Аккаунты", + "description": "Выберите аккаунт, который хотите использовать.", + "addAnother": "Добавить другой аккаунт", + "noResults": "Аккаунты не найдены" + }, + "logout": { + "title": "Выход", + "description": "Выберите аккаунт, который хотите удалить", + "noResults": "Аккаунты не найдены", + "clear": "Удалить сессию", + "verifiedAt": "Последняя активность: {time}", + "success": { + "title": "Выход выполнен успешно", + "description": "Вы успешно вышли из системы." + } + }, + "loginname": { + "title": "С возвращением!", + "description": "Введите свои данные для входа.", + "register": "Зарегистрировать нового пользователя", + "submit": "Продолжить" + }, + "password": { + "verify": { + "title": "Пароль", + "description": "Введите ваш пароль.", + "resetPassword": "Сбросить пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "codeSent": "Код отправлен на ваш адрес электронной почты.", + "noCodeReceived": "Не получили код?", + "resend": "Отправить код повторно", + "submit": "Продолжить" + }, + "change": { + "title": "Изменить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "idp": { + "title": "Войти через SSO", + "description": "Выберите одного из провайдеров для входа", + "orSignInWith": "или войти через", + "signInWithApple": "Войти через Apple", + "signInWithGoogle": "Войти через Google", + "signInWithAzureAD": "Войти через AzureAD", + "signInWithGithub": "Войти через GitHub", + "signInWithGitlab": "Войти через GitLab", + "loginSuccess": { + "title": "Вход выполнен успешно", + "description": "Вы успешно вошли в систему!" + }, + "linkingSuccess": { + "title": "Аккаунт привязан", + "description": "Аккаунт успешно привязан!" + }, + "registerSuccess": { + "title": "Регистрация завершена", + "description": "Вы успешно зарегистрировались!" + }, + "loginError": { + "title": "Ошибка входа", + "description": "Произошла ошибка при попытке входа." + }, + "linkingError": { + "title": "Ошибка привязки аккаунта", + "description": "Произошла ошибка при попытке привязать аккаунт." + }, + "completeRegister": { + "title": "Завершите регистрацию", + "description": "Завершите регистрацию вашего аккаунта." + } + }, + "ldap": { + "title": "Войти через LDAP", + "description": "Введите ваши учетные данные LDAP.", + "username": "Имя пользователя", + "password": "Пароль", + "submit": "Продолжить" + }, + "mfa": { + "verify": { + "title": "Подтвердите вашу личность", + "description": "Выберите один из следующих факторов.", + "noResults": "Нет доступных методов двухфакторной аутентификации" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Выберите один из следующих методов.", + "skip": "Пропустить" + } + }, + "otp": { + "verify": { + "title": "Подтверждение 2FA", + "totpDescription": "Введите код из приложения-аутентификатора.", + "smsDescription": "Введите код, полученный по SMS.", + "emailDescription": "Введите код, полученный по email.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "totpDescription": "Отсканируйте QR-код в приложении-аутентификаторе.", + "smsDescription": "Введите номер телефона для получения кода по SMS.", + "emailDescription": "Введите email для получения кода.", + "totpRegisterDescription": "Отсканируйте QR-код или перейдите по ссылке вручную.", + "submit": "Продолжить" + } + }, + "passkey": { + "verify": { + "title": "Аутентификация с помощью пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "usePassword": "Использовать пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "info": { + "description": "Пасскей — метод аутентификации через устройство (отпечаток пальца, Apple FaceID и аналоги).", + "link": "Аутентификация без пароля" + }, + "skip": "Пропустить", + "submit": "Продолжить" + } + }, + "u2f": { + "verify": { + "title": "Подтверждение 2FA", + "description": "Подтвердите аккаунт с помощью устройства." + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Настройте устройство как второй фактор.", + "submit": "Продолжить" + } + }, + "register": { + "methods": { + "passkey": "Пасскей", + "password": "Пароль" + }, + "disabled": { + "title": "Регистрация отключена", + "description": "Регистрация недоступна. Обратитесь к администратору." + }, + "missingdata": { + "title": "Недостаточно данных", + "description": "Укажите email, имя и фамилию для регистрации." + }, + "title": "Регистрация", + "description": "Создайте свой аккаунт ZITADEL.", + "noMethodAvailableWarning": "Нет доступных методов аутентификации. Обратитесь к администратору.", + "selectMethod": "Выберите метод аутентификации", + "agreeTo": "Для регистрации необходимо принять условия:", + "termsOfService": "Условия использования", + "privacyPolicy": "Политика конфиденциальности", + "submit": "Продолжить", + "orUseIDP": "или используйте Identity Provider", + "password": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "invite": { + "title": "Пригласить пользователя", + "description": "Укажите email и имя пользователя для приглашения.", + "info": "Пользователь получит email с инструкциями.", + "notAllowed": "Ваши настройки не позволяют приглашать пользователей.", + "submit": "Продолжить", + "success": { + "title": "Пользователь приглашён", + "description": "Письмо успешно отправлено.", + "verified": "Пользователь приглашён и уже подтвердил email.", + "notVerifiedYet": "Пользователь приглашён. Он получит email с инструкциями.", + "submit": "Пригласить другого пользователя" + } + }, + "signedin": { + "title": "Добро пожаловать, {user}!", + "description": "Вы вошли в систему.", + "continue": "Продолжить", + "error": { + "title": "Ошибка", + "description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова." + } + }, + "verify": { + "userIdMissing": "Не указан userId!", + "successTitle": "Пользователь подтверждён", + "successDescription": "Пользователь успешно подтверждён.", + "setupAuthenticator": "Настроить аутентификатор", + "verify": { + "title": "Подтверждение пользователя", + "description": "Введите код из письма подтверждения.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "codeSent": "Код отправлен на ваш email.", + "submit": "Продолжить" + } + }, + "authenticator": { + "title": "Выбор метода аутентификации", + "description": "Выберите предпочитаемый метод аутентификации", + "noMethodsAvailable": "Нет доступных методов аутентификации", + "allSetup": "Аутентификатор уже настроен!", + "linkWithIDP": "или привязать через Identity Provider" + }, + "device": { + "usercode": { + "title": "Код устройства", + "description": "Введите код.", + "submit": "Продолжить" + }, + "request": { + "title": "{appName} хочет подключиться:", + "description": "{appName} получит доступ к:", + "disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", + "submit": "Разрешить", + "deny": "Запретить" + }, + "scope": { + "openid": "Проверка вашей личности.", + "email": "Доступ к вашему адресу электронной почты.", + "profile": "Доступ к полной информации вашего профиля.", + "offline_access": "Разрешить офлайн-доступ к вашему аккаунту." + } + }, + "error": { + "noUserCode": "Не указан код пользователя!", + "noDeviceRequest": "Не найдена ни одна заявка на устройство.", + "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", + "sessionExpired": "Ваша сессия истекла. Войдите снова.", + "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", + "tryagain": "Попробовать снова" + } +} diff --git a/login/apps/login/locales/zh.json b/login/apps/login/locales/zh.json new file mode 100644 index 0000000000..5a9cb3a4eb --- /dev/null +++ b/login/apps/login/locales/zh.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "返回" + }, + "accounts": { + "title": "账户", + "description": "选择您想使用的账户。", + "addAnother": "添加另一个账户", + "noResults": "未找到账户" + }, + "logout": { + "title": "注销", + "description": "选择您想注销的账户", + "noResults": "未找到账户", + "clear": "注销会话", + "verifiedAt": "最后活动时间:{time}", + "success": { + "title": "注销成功", + "description": "您已成功注销。" + } + }, + "loginname": { + "title": "欢迎回来!", + "description": "请输入您的登录信息。", + "register": "注册新用户", + "submit": "继续" + }, + "password": { + "verify": { + "title": "密码", + "description": "请输入您的密码。", + "resetPassword": "重置密码", + "submit": "继续" + }, + "set": { + "title": "设置密码", + "description": "为您的账户设置密码", + "codeSent": "验证码已发送到您的邮箱。", + "noCodeReceived": "没有收到验证码?", + "resend": "重发验证码", + "submit": "继续" + }, + "change": { + "title": "更改密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "idp": { + "title": "使用 SSO 登录", + "description": "选择以下提供商中的一个进行登录", + "orSignInWith": "或使用以下方式登录", + "signInWithApple": "用 Apple 登录", + "signInWithGoogle": "用 Google 登录", + "signInWithAzureAD": "用 AzureAD 登录", + "signInWithGithub": "用 GitHub 登录", + "signInWithGitlab": "用 GitLab 登录", + "loginSuccess": { + "title": "登录成功", + "description": "您已成功登录!" + }, + "linkingSuccess": { + "title": "账户已链接", + "description": "您已成功链接您的账户!" + }, + "registerSuccess": { + "title": "注册成功", + "description": "您已成功注册!" + }, + "loginError": { + "title": "登录失败", + "description": "登录时发生错误。" + }, + "linkingError": { + "title": "账户链接失败", + "description": "链接账户时发生错误。" + }, + "completeRegister": { + "title": "完成注册", + "description": "完成您的账户注册。" + } + }, + "ldap": { + "title": "使用 LDAP 登录", + "description": "请输入您的 LDAP 凭据。", + "username": "用户名", + "password": "密码", + "submit": "继续" + }, + "mfa": { + "verify": { + "title": "验证您的身份", + "description": "选择以下的一个因素。", + "noResults": "没有可设置的第二因素。" + }, + "set": { + "title": "设置双因素认证", + "description": "选择以下的一个第二因素。", + "skip": "跳过" + } + }, + "otp": { + "verify": { + "title": "验证双因素", + "totpDescription": "请输入认证应用程序中的验证码。", + "smsDescription": "输入通过短信收到的验证码。", + "emailDescription": "输入通过电子邮件收到的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + }, + "set": { + "title": "设置双因素认证", + "totpDescription": "使用认证应用程序扫描二维码。", + "smsDescription": "输入您的电话号码以接收短信验证码。", + "emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。", + "totpRegisterDescription": "扫描二维码或手动导航到URL。", + "submit": "继续" + } + }, + "passkey": { + "verify": { + "title": "使用密钥认证", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "usePassword": "使用密码", + "submit": "继续" + }, + "set": { + "title": "设置密钥", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "info": { + "description": "密钥是在设备上如指纹、Apple FaceID 或类似的认证方法。", + "link": "无密码认证" + }, + "skip": "跳过", + "submit": "继续" + } + }, + "u2f": { + "verify": { + "title": "验证双因素", + "description": "使用您的设备验证帐户。" + }, + "set": { + "title": "设置双因素认证", + "description": "设置设备为第二因素。", + "submit": "继续" + } + }, + "register": { + "methods": { + "passkey": "密钥", + "password": "密码" + }, + "disabled": { + "title": "注册已禁用", + "description": "您的设置不允许注册新用户。" + }, + "missingdata": { + "title": "缺少数据", + "description": "请提供所有必需的数据。" + }, + "title": "注册", + "description": "创建您的 ZITADEL 账户。", + "noMethodAvailableWarning": "没有可用的认证方法。请联系您的系统管理员。", + "selectMethod": "选择您想使用的认证方法", + "agreeTo": "注册即表示您同意条款和条件", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "submit": "继续", + "orUseIDP": "或使用身份提供者", + "password": { + "title": "设置密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "invite": { + "title": "邀请用户", + "description": "提供您想邀请的用户的电子邮箱地址和姓名。", + "info": "用户将收到一封包含进一步说明的电子邮件。", + "notAllowed": "您的设置不允许邀请用户。", + "submit": "继续", + "success": { + "title": "用户已邀请", + "description": "邮件已成功发送。", + "verified": "用户已被邀请并已验证其电子邮件。", + "notVerifiedYet": "用户已被邀请。他们将收到一封包含进一步说明的电子邮件。", + "submit": "邀请另一位用户" + } + }, + "signedin": { + "title": "欢迎 {user}!", + "description": "您已登录。", + "continue": "继续", + "error": { + "title": "错误", + "description": "登录时发生错误。" + } + }, + "verify": { + "userIdMissing": "未提供用户 ID!", + "successTitle": "用户已验证", + "successDescription": "用户已成功验证。", + "setupAuthenticator": "设置认证器", + "verify": { + "title": "验证用户", + "description": "输入验证邮件中的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "codeSent": "刚刚发送了一封包含验证码的电子邮件。", + "submit": "继续" + } + }, + "authenticator": { + "title": "选择认证方式", + "description": "选择您想使用的认证方法", + "noMethodsAvailable": "没有可用的认证方法", + "allSetup": "您已经设置好了一个认证器!", + "linkWithIDP": "或将其与身份提供者关联" + }, + "device": { + "usercode": { + "title": "设备代码", + "description": "输入代码。", + "submit": "继续" + }, + "request": { + "title": "{appName} 想要连接:", + "description": "{appName} 将访问:", + "disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", + "submit": "允许", + "deny": "拒绝" + }, + "scope": { + "openid": "验证您的身份。", + "email": "访问您的电子邮件地址。", + "profile": "访问您的完整个人资料信息。", + "offline_access": "允许离线访问您的账户。" + } + }, + "error": { + "noUserCode": "未提供用户代码!", + "noDeviceRequest": "没有找到设备请求。", + "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", + "sessionExpired": "当前会话已过期,请重新登录。", + "failedLoading": "加载数据失败,请再试一次。", + "tryagain": "重试" + } +} diff --git a/login/apps/login/next-env-vars.d.ts b/login/apps/login/next-env-vars.d.ts new file mode 100644 index 0000000000..b7a525858c --- /dev/null +++ b/login/apps/login/next-env-vars.d.ts @@ -0,0 +1,33 @@ +declare namespace NodeJS { + interface ProcessEnv { + // Allow any environment variable that matches the pattern + [key: `${string}_AUDIENCE`]: string; // The system api url + [key: `${string}_SYSTEM_USER_ID`]: string; // The service user id + [key: `${string}_SYSTEM_USER_PRIVATE_KEY`]: string; // The service user private key + + AUDIENCE: string; // The fallback system api url + SYSTEM_USER_ID: string; // The fallback service user id + SYSTEM_USER_PRIVATE_KEY: string; // The fallback service user private key + + /** + * The Zitadel API url + */ + ZITADEL_API_URL: string; + + /** + * The service user token + */ + ZITADEL_SERVICE_USER_TOKEN: string; + + /** + * Optional: wheter a user must have verified email + */ + EMAIL_VERIFICATION: string; + + /** + * Optional: custom request headers to be added to every request + * Split by comma, key value pairs separated by colon + */ + CUSTOM_REQUEST_HEADERS?: string; + } +} diff --git a/login/apps/login/next-env.d.ts b/login/apps/login/next-env.d.ts new file mode 100755 index 0000000000..1b3be0840f --- /dev/null +++ b/login/apps/login/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/login/apps/login/next.config.mjs b/login/apps/login/next.config.mjs new file mode 100755 index 0000000000..b84f11a230 --- /dev/null +++ b/login/apps/login/next.config.mjs @@ -0,0 +1,83 @@ +import createNextIntlPlugin from "next-intl/plugin"; +import { DEFAULT_CSP } from "./constants/csp.js"; + +const withNextIntl = createNextIntlPlugin(); + +/** @type {import('next').NextConfig} */ + +const secureHeaders = [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Referrer-Policy", + value: "origin-when-cross-origin", + }, + { + key: "X-Frame-Options", + value: "SAMEORIGIN", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + { + key: "Content-Security-Policy", + value: `${DEFAULT_CSP} frame-ancestors 'none'`, + }, + { key: "X-Frame-Options", value: "deny" }, +]; + +const imageRemotePatterns = [ + { + protocol: "http", + hostname: "localhost", + port: "8080", + pathname: "/**", + }, + { + protocol: "https", + hostname: "*.zitadel.*", + port: "", + pathname: "/**", + }, +]; + +if (process.env.ZITADEL_API_URL) { + imageRemotePatterns.push({ + protocol: "https", + hostname: process.env.ZITADEL_API_URL?.replace("https://", "") || "", + port: "", + pathname: "/**", + }); +} + +const nextConfig = { + basePath: process.env.NEXT_PUBLIC_BASE_PATH, + output: process.env.NEXT_OUTPUT_MODE || undefined, + reactStrictMode: true, // Recommended for the `pages` directory, default in `app`. + experimental: { + dynamicIO: true, + }, + images: { + remotePatterns: imageRemotePatterns, + }, + eslint: { + ignoreDuringBuilds: true, + }, + async headers() { + return [ + { + source: "/:path*", + headers: secureHeaders, + }, + ]; + }, +}; + +export default withNextIntl(nextConfig); diff --git a/login/apps/login/package.json b/login/apps/login/package.json new file mode 100644 index 0000000000..f498b912c2 --- /dev/null +++ b/login/apps/login/package.json @@ -0,0 +1,75 @@ +{ + "name": "@zitadel/login", + "private": true, + "type": "module", + "scripts": { + "dev": "pnpm next dev --turbopack", + "test:unit": "pnpm vitest", + "test:unit:standalone": "pnpm test:unit", + "test:unit:watch": "pnpm test:unit --watch", + "lint": "pnpm exec next lint && pnpm exec prettier --check .", + "lint:fix": "pnpm exec prettier --write .", + "lint-staged": "lint-staged", + "build": "pnpm exec next build", + "build:login:standalone": "NEXT_PUBLIC_BASE_PATH=/ui/v2/login NEXT_OUTPUT_MODE=standalone pnpm build", + "start": "pnpm build && pnpm exec next start", + "start:built": "pnpm exec next start", + "clean": "pnpm mock:stop && rm -rf .turbo && rm -rf node_modules && rm -rf .next" + }, + "git": { + "pre-commit": "lint-staged" + }, + "lint-staged": { + "*": "prettier --write --ignore-unknown" + }, + "dependencies": { + "@headlessui/react": "^2.1.9", + "@heroicons/react": "2.1.3", + "@tailwindcss/forms": "0.5.7", + "@vercel/analytics": "^1.2.2", + "@zitadel/client": "workspace:*", + "@zitadel/proto": "workspace:*", + "clsx": "1.2.1", + "copy-to-clipboard": "^3.3.3", + "deepmerge": "^4.3.1", + "lucide-react": "0.469.0", + "moment": "^2.29.4", + "next": "15.4.0-canary.86", + "next-intl": "^3.25.1", + "next-themes": "^0.2.1", + "nice-grpc": "2.0.1", + "qrcode.react": "^3.1.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-hook-form": "7.39.5", + "tinycolor2": "1.4.2", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@bufbuild/buf": "^1.53.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@types/ms": "2.1.0", + "@types/node": "^22.14.1", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@types/tinycolor2": "1.4.3", + "@types/uuid": "^10.0.0", + "@vercel/git-hooks": "1.0.0", + "@zitadel/eslint-config": "workspace:*", + "@zitadel/prettier-config": "workspace:*", + "@zitadel/tailwind-config": "workspace:*", + "@zitadel/tsconfig": "workspace:*", + "autoprefixer": "10.4.21", + "grpc-tools": "1.13.0", + "jsdom": "^26.1.0", + "lint-staged": "15.5.1", + "make-dir-cli": "4.0.0", + "postcss": "8.5.3", + "prettier-plugin-tailwindcss": "0.6.11", + "sass": "^1.87.0", + "tailwindcss": "3.4.14", + "ts-proto": "^2.7.0", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login/postcss.config.cjs b/login/apps/login/postcss.config.cjs new file mode 100644 index 0000000000..12a703d900 --- /dev/null +++ b/login/apps/login/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/login/apps/login/prettier.config.mjs b/login/apps/login/prettier.config.mjs new file mode 100644 index 0000000000..6df557c2fd --- /dev/null +++ b/login/apps/login/prettier.config.mjs @@ -0,0 +1 @@ +export { default } from "@zitadel/prettier-config"; diff --git a/login/apps/login/public/checkbox.svg b/login/apps/login/public/checkbox.svg new file mode 100644 index 0000000000..94a3298ae6 --- /dev/null +++ b/login/apps/login/public/checkbox.svg @@ -0,0 +1 @@ + diff --git a/login/apps/login/public/favicon.ico b/login/apps/login/public/favicon.ico new file mode 100644 index 0000000000..a901eddc34 Binary files /dev/null and b/login/apps/login/public/favicon.ico differ diff --git a/login/apps/login/public/favicon/android-chrome-192x192.png b/login/apps/login/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000..f22bd442e6 Binary files /dev/null and b/login/apps/login/public/favicon/android-chrome-192x192.png differ diff --git a/login/apps/login/public/favicon/android-chrome-512x512.png b/login/apps/login/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000..6987ed11b4 Binary files /dev/null and b/login/apps/login/public/favicon/android-chrome-512x512.png differ diff --git a/login/apps/login/public/favicon/apple-touch-icon.png b/login/apps/login/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000000..4816102015 Binary files /dev/null and b/login/apps/login/public/favicon/apple-touch-icon.png differ diff --git a/login/apps/login/public/favicon/browserconfig.xml b/login/apps/login/public/favicon/browserconfig.xml new file mode 100644 index 0000000000..75efb24254 --- /dev/null +++ b/login/apps/login/public/favicon/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #000000 + + + diff --git a/login/apps/login/public/favicon/favicon-16x16.png b/login/apps/login/public/favicon/favicon-16x16.png new file mode 100644 index 0000000000..4f45702ca9 Binary files /dev/null and b/login/apps/login/public/favicon/favicon-16x16.png differ diff --git a/login/apps/login/public/favicon/favicon-32x32.png b/login/apps/login/public/favicon/favicon-32x32.png new file mode 100644 index 0000000000..a598da05eb Binary files /dev/null and b/login/apps/login/public/favicon/favicon-32x32.png differ diff --git a/login/apps/login/public/favicon/favicon.ico b/login/apps/login/public/favicon/favicon.ico new file mode 100644 index 0000000000..af98450595 Binary files /dev/null and b/login/apps/login/public/favicon/favicon.ico differ diff --git a/login/apps/login/public/favicon/mstile-150x150.png b/login/apps/login/public/favicon/mstile-150x150.png new file mode 100644 index 0000000000..ab518480e6 Binary files /dev/null and b/login/apps/login/public/favicon/mstile-150x150.png differ diff --git a/login/apps/login/public/favicon/site.webmanifest b/login/apps/login/public/favicon/site.webmanifest new file mode 100644 index 0000000000..567c2c6549 --- /dev/null +++ b/login/apps/login/public/favicon/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "ZITADEL Login", + "short_name": "ZITADEL Login", + "icons": [ + { + "src": "/favicon/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#000000", + "background_color": "#000000", + "display": "standalone" +} diff --git a/login/apps/login/public/grid-dark.svg b/login/apps/login/public/grid-dark.svg new file mode 100644 index 0000000000..d467ad6de0 --- /dev/null +++ b/login/apps/login/public/grid-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/login/apps/login/public/grid-light.svg b/login/apps/login/public/grid-light.svg new file mode 100644 index 0000000000..114c1186fe --- /dev/null +++ b/login/apps/login/public/grid-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/login/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg b/login/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg new file mode 100644 index 0000000000..4a4e8be71b --- /dev/null +++ b/login/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/login/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg b/login/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg new file mode 100644 index 0000000000..33ea6b583b --- /dev/null +++ b/login/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/login/apps/login/public/zitadel-logo-dark.svg b/login/apps/login/public/zitadel-logo-dark.svg new file mode 100644 index 0000000000..6dcfe06e6d --- /dev/null +++ b/login/apps/login/public/zitadel-logo-dark.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/login/apps/login/public/zitadel-logo-light.svg b/login/apps/login/public/zitadel-logo-light.svg new file mode 100644 index 0000000000..d48a5eeb94 --- /dev/null +++ b/login/apps/login/public/zitadel-logo-light.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/login/apps/login/readme.md b/login/apps/login/readme.md new file mode 100644 index 0000000000..ca7070a901 --- /dev/null +++ b/login/apps/login/readme.md @@ -0,0 +1,394 @@ +# ZITADEL Login UI + +This is going to be our next UI for the hosted login. It's based on Next.js 13 and its introduced `app/` directory. + +## Flow Diagram + +This diagram shows the available pages and flows. + +> Note that back navigation or retries are not displayed. + +```mermaid + flowchart TD + A[Start] --> register + A[Start] --> accounts + A[Start] --> loginname + loginname -- signInWithIDP --> idp-success + loginname -- signInWithIDP --> idp-failure + idp-success --> B[signedin] + loginname --> password + loginname -- hasPasskey --> passkey + loginname -- allowRegister --> register + passkey-add --passwordAllowed --> password + passkey -- hasPassword --> password + passkey --> B[signedin] + password -- hasMFA --> mfa + password -- allowPasskeys --> passkey-add + password -- reset --> password-set + email -- reset --> password-set + password-set --> B[signedin] + password-change --> B[signedin] + password -- userstate=initial --> password-change + + mfa --> otp + otp --> B[signedin] + mfa--> u2f + u2f -->B[signedin] + register -- password/passkey --> B[signedin] + password --> B[signedin] + password-- forceMFA -->mfaset + mfaset --> u2fset + mfaset --> otpset + u2fset --> B[signedin] + otpset --> B[signedin] + accounts--> loginname + password -- not verified yet -->verify + register-- withpassword -->verify + passkey-- notVerified --> verify + verify --> B[signedin] +``` + +### /loginname + +This page shows a loginname field and Identity Providers to login or register. +If `loginSettings(org?).allowRegister` is `true`, it also shows a link to jump to /register + +/loginame + +Requests to the APIs made: + +- `getLoginSettings(org?)` +- `getLegalAndSupportSettings(org?)` +- `getIdentityProviders(org?)` +- `getBrandingSettings(org?)` +- `getActiveIdentityProviders(org?)` +- `startIdentityProviderFlow` +- `listUsers(org?)` +- `listAuthenticationMethodTypes` +- `getOrgsByDomain` +- `createSession()` +- `getSession()` + +After a loginname is entered, a `listUsers` request is made using the loginName query to identify already registered users. + +**USER FOUND:** If only one user is found, we query `listAuthenticationMethodTypes` to identify future steps. +If no authentication methods are found, we render an error stating: _User has no available authentication methods._ (exception see below.) +Now if only one method is found, we continue with the corresponding step (/password, /passkey). +If multiple methods are set, we prefer passkeys over any other method, so we redirect to /passkey, second option is IDP, and third is password. +If password is the next step, we check `loginSettings.passkeysType` for PasskeysType.ALLOWED, and prompt the user to setup passkeys afterwards. + +**NO USER FOUND:** If no user is found, we check whether registering is allowed using `loginSettings.allowRegister`. +If `loginSettings?.allowUsernamePassword` is not allowed we continue to check for available IDPs. If a single IDP is available, we directly redirect the user to signup. + +If no single IDP is set, we check for `loginSettings.allowUsernamePassword` and if no organization is set as context, we check whether we can discover a organization from the loginname of the user (using: `getOrgsByDomain`). Then if an organization is found, we check whether domainDiscovery is allowed on it and redirect the user to /register page including the discovered domain or without. + +If no previous condition is met we throw an error stating the user was not found. + +**EXCEPTIONS:** If the outcome after this order produces a no authentication methods found, or user not found, we check whether `loginSettings?.ignoreUnknownUsernames` is set to `true` as in this case we redirect to the /password page regardless (to prevent username guessing). + +> NOTE: This page at this stage beeing ignores local sessions and executes a reauthentication. This is a feature which is not implemented yet. + +> NOTE: We ignore `loginSettings.allowExternalIdp` as the information whether IDPs are available comes as response from `getActiveIdentityProviders(org?)`. If a user has a cookie for the same loginname, a new session is created regardless and overwrites the old session. The old session is not deleted from the login as for now. + +> NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f methods or passkeys. The check whether a user should be redirected to one of the pages `/passkey` or `/u2f`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615) + +### /password + +This page shows a password field to hydrate the current session with password as a factor. +Below the password field, a reset password link is shown which allows to send a reset email. + +/password + +Requests to the APIs made: + +- `getLoginSettings(org?)` +- `getBrandingSettings(org?)` +- `listAuthenticationMethodTypes` +- `getSession()` +- `updateSession()` +- `listUsers()` +- `getUserById()` + +**MFA AVAILABLE:** After the password has been submitted, additional authentication methods are loaded. +If the user has set up an additional **single** second factor, it is redirected to add the next factor. Depending on the available method he is redirected to `/otp/time-based`,`/otp/sms?`, `/otp/email?` or `/u2f?`. If the user has multiple second factors, he is redirected to `/mfa` to select his preferred method to continue. + +**NO MFA, USER STATE INITIAL** If the user has no MFA methods and is in an initial state, we redirect to `/password/change` where a new password can be set. + +**NO MFA, FORCE MFA:** If no MFA method is available, and the settings force MFA, the user is sent to `/mfa/set` which prompts to setup a second factor. + +**PROMPT PASSKEY** If the settings do not enforce MFA, we check if passkeys are allowed with `loginSettings?.passkeysType == PasskeysType.ALLOWED` and redirect the user to `/passkey/set` if no passkeys are setup. This step can be skipped. + +If none of the previous conditions apply, we continue to sign in. + +> NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f methods or passkeys. The check whether a user should be redirected to one of the pages `/passkey` or `/u2f`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615) + +### /password/change + +This page allows to change the password. It is used after a user is in an initial state and is required to change the password, or it can be directly invoked with an active session. + +/password/change + +Requests to the APIs made: + +- `getLoginSettings(org?)` +- `getPasswordComplexitySettings(user?)` +- `getBrandingSettings(org?)` +- `getSession()` +- `setPassword()` + +> NOTE: The request to change the password is using the session of the user itself not the service user, therefore no code is required. + +### /password/set + +This page allows to set a password. It is used after a user has requested to reset the password on the `/password` page. + +/password/set + +Requests to the APIs made: + +- `getLoginSettings(org?)` +- `getPasswordComplexitySettings(user?)` +- `getBrandingSettings(org?)` +- `getUserByID()` +- `setPassword()` + +The page allows to enter a code or be invoked directly from a email link which prefills the code. The user can enter a new password and submit. + +### /otp/[method] + +This page shows a code field to check an otp method. The session of the user is then hydrated with the respective factor. Supported methods are `time-based`, `sms` and `email`. + +/otp/[method] + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `updateSession()` + +If `email` or `sms` is requested as method, the current session of the user is updated to request the challenge. This will trigger an email or sms which can be entered in the code field. +The `time-based` (TOTP) method does not require a trigger, therefore no `updateSession()` is performed and no resendLink under the code field is shown. + +The submission of the code updates the session and continues to sign in the user. + +### /u2f + +This page requests a webAuthN challenge for the user and updates the session afterwards. + +/u2f + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `updateSession()` + +When updating the session for the webAuthN challenge, we set `userVerificationRequirement` to `UserVerificationRequirement.DISCOURAGED` as this will request the webAuthN method as second factor and not as primary method. +After updating the session, the user is **always** signed in. :warning: required as this page is a follow up for setting up a u2f method. + +### /passkey + +This page requests a webAuthN challenge for the user and updates the session afterwards. +It is invoked directly after setting up a passkey `/passkey/set` or when loggin in a user after `/loginname`. + +/passkey + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `updateSession()` + +When updating the session for the webAuthN challenge, we set `userVerificationRequirement` to `UserVerificationRequirement.REQUIRED` as this will request the webAuthN method as primary method to login. +After updating the session, the user is **always** signed in. :warning: required as this page is a follow up for setting up a passkey + +> NOTE: This page currently does not check whether a user contains passkeys. If this method is not available, this page should not be used. + +### /mfa/set + +This page loads login settings and the authentication methods for a user and shows setup options. + +/mfa/set + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getLoginSettings(user.org)` :warning: context taken from session +- `getSession()` +- `listAuthenticationMethodTypes()` +- `getUserByID()` + +If a user has already setup a certain method, a checkbox is shown alongside the button and the button is disabled. +OTP Email and OTP SMS only show up if the user has verified email or phone. +If the user chooses a method he is redirected to one of `/otp/time-based/set`, `/u2f/set`, `/otp/email/set`, or `/otp/sms/set`. +At the moment, U2F methods are hidden if a method is already added on the users resource. Reasoning is that the page should only be invoked for prompts. A self service page which shows up multiple u2f factors is implemented at a later stage. + +> NOTE: The session and therefore the user factor defines which login settings are checked for available options. + +> NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f or passkeys. The check whether a user should be redirected to one of the pages `/passkey/set` or `/u2f/set`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615) + +### /passkey/set + +This page sets a passkey method for a user. This page can be either enforced, or optional depending on the Login Settings. + + +/passkey/set + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `createPasskeyRegistrationLink()` TODO: check if this can be used with the session token (mfa required (AUTHZ-Kl3p0)) +- `registerPasskey()` +- `verifyPasskey()` + +If the loginname decides to redirect the user to this page, a button to skip appears which will sign the user in afterwards. +After a passkey is registered, we redirect the user to `/passkey` to verify it again and sign in with the new method. The `createPasskeyRegistrationLink()` uses the token of the session which is determined by the flow. + +> NOTE: this page allows passkeys to be created only if the current session is valid (self service), or no authentication method is set (register). TODO: to be implemented. + +> NOTE: Redirecting the user to `/passkey` will not be required in future and the currently used session will be hydrated directly after registering. (https://github.com/zitadel/zitadel/issues/8611) + +### /otp/time-based/set + +This page registers a time based OTP method for a user. + +/otp/time-based/set + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `registerTOTP()` +- `verifyTOTP()` + +After the setup is done, the user is redirected to verify the TOTP method on `/otp/time-based`. + +> NOTE: Redirecting the user to `/otp/time-based` will not be required in future and the currently used session will be hydrated directly. (https://github.com/zitadel/zitadel/issues/8611) + +### /otp/email/set /otp/sms/set + +This page registers either an Email OTP method or SMS OTP method for a user. + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `addOTPEmail()` / `addOTPSMS()` + +This page directly calls `addOTPEmail()` or `addOTPSMS()` when invoked and shows a success message. +Right afterwards, redirects to verify the method. + +### /u2f/set + +This page registers a U2F method for a user. + +/u2f/set + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `registerU2F()` :warning: TODO: check if this can be used with the session token (mfa required (AUTHZ-Kl3p0)) +- `verifyU2FRegistration()` + +After a u2f method is registered, we redirect the user to `/passkey` to verify it again and sign in with the new method. The `createPasskeyRegistrationLink()` uses the token of the session which is determined by the flow. + +> NOTE: Redirecting the user to `/passkey` will not be required in future and the currently used session will be hydrated directly after registering. (https://github.com/zitadel/zitadel/issues/8611) + +### /register + +This page shows a register page, which gets firstname and lastname of a user as well as the email. It offers to setup a user, using password or passkeys. + +/register + +register with password + +Requests to the APIs made: + +- `listOrganizations()` :warning: TODO: determine the default organization if no context is set +- `getLegalAndSupportSettings(org)` +- `getPasswordComplexitySettings()` +- `getBrandingSettings()` +- `addHumanUser()` +- `createSession()` +- `getSession()` + +To register a user, the organization where the resource will be created is determined first. If no context is provided via url, we fall back to the default organization of the instance. + +**PASSWORD:** If a password is set, the user is created as a resource, then a session using the password check is created immediately. After creating the session, the user is directly logged in and eventually redirected back to the application. + +**PASSKEY:** If passkey is selected, the user is created as a resource first, then a session using the userId is created immediately. This session does not yet contain a check, we therefore redirect the user to setup a passkey at `/passkey/set`. As the passkey set page verifies the passkey right afterwards, the process ends with a signed in user. + +> NOTE: https://github.com/zitadel/zitadel/issues/8616 to determine the default organization of an instance must be implemented in order to correctly use the legal-, login-, branding- and complexitysettings. + +> NOTE: TODO: check which methods are allowed in the login settings, loginSettings.allowUsernamePassword / check for passkey + +### /idp + +This page doubles as /loginname but limits it to choose from IDPs + +/idp + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getActiveIdentityProviders(org?)` +- `startIdentityProviderFlow()` + +### /idp/[method]/success /idp/[method]/failure + +Both /success and /failure pages are designed to intercept the responses from the IDPs and decide on how to continue with the process. + +### /verify + +This page verifies the email to be valid. It page of the login can also be invoked without an active session. +The context of the user is taken from the url and is set in the email template. + +/accounts + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getLoginSettings(org?)` +- `verifyEmail()` + +If the page is invoked with an active session (right after a register with password), the user is signed in or redirected to the loginname if no context is known. + +> NOTE: This page will be extended to support invitations. In such case, authentication methods of the user are loaded and if none available, shown as possible next step (`/passkey/set`, `password/set`). + +### /accounts + +This page shows an overview of all current sessions. +Sessions with invalid token show a red dot on the right side, Valid session a green dot, and its last verified date. + +/accounts + +This page is a starting point for self management, reauthentication, or can be used to clear local sessions. +This page is also shown if used with OIDC and `prompt: select_account`. + +On all pages, where the current user is shown, you can jump to this page. This way, a session can quickly be reused if valid. + +jump to accounts + +### /signedin + +This is a success page which shows a completed login flow for a user, which did navigate to the login without a OIDC auth requrest. From here device authorization flows are completed. It checks if the requestId param of starts with `device_` and then executes the `authorizeOrDenyDeviceAuthorization` command. + +/signedin + +In future, self service options to jump to are shown below, like: + +- change password +- setup passkeys +- setup mfa +- change profile +- logout + +> NOTE: This page has to be explicitly enabled or act as a fallback if no default redirect is set. + +## Currently NOT Supported + +- forceMFA on login settings is not checked for IDPs + +Also note that IDP logins are considered as valid MFA. An additional MFA check will be implemented in future if enforced. diff --git a/login/apps/login/screenshots/accounts.png b/login/apps/login/screenshots/accounts.png new file mode 100644 index 0000000000..a8591141c6 Binary files /dev/null and b/login/apps/login/screenshots/accounts.png differ diff --git a/login/apps/login/screenshots/accounts_jumpto.png b/login/apps/login/screenshots/accounts_jumpto.png new file mode 100644 index 0000000000..0fd126bf4c Binary files /dev/null and b/login/apps/login/screenshots/accounts_jumpto.png differ diff --git a/login/apps/login/screenshots/collage.png b/login/apps/login/screenshots/collage.png new file mode 100644 index 0000000000..9d5a9c35c8 Binary files /dev/null and b/login/apps/login/screenshots/collage.png differ diff --git a/login/apps/login/screenshots/idp.png b/login/apps/login/screenshots/idp.png new file mode 100644 index 0000000000..9bf58c69b0 Binary files /dev/null and b/login/apps/login/screenshots/idp.png differ diff --git a/login/apps/login/screenshots/loginname.png b/login/apps/login/screenshots/loginname.png new file mode 100644 index 0000000000..342e60799e Binary files /dev/null and b/login/apps/login/screenshots/loginname.png differ diff --git a/login/apps/login/screenshots/mfa.png b/login/apps/login/screenshots/mfa.png new file mode 100644 index 0000000000..1fd73f205c Binary files /dev/null and b/login/apps/login/screenshots/mfa.png differ diff --git a/login/apps/login/screenshots/mfaset.png b/login/apps/login/screenshots/mfaset.png new file mode 100644 index 0000000000..c00ee4edf5 Binary files /dev/null and b/login/apps/login/screenshots/mfaset.png differ diff --git a/login/apps/login/screenshots/otp.png b/login/apps/login/screenshots/otp.png new file mode 100644 index 0000000000..3818a5ad5f Binary files /dev/null and b/login/apps/login/screenshots/otp.png differ diff --git a/login/apps/login/screenshots/otpset.png b/login/apps/login/screenshots/otpset.png new file mode 100644 index 0000000000..f75c2154c7 Binary files /dev/null and b/login/apps/login/screenshots/otpset.png differ diff --git a/login/apps/login/screenshots/passkey.png b/login/apps/login/screenshots/passkey.png new file mode 100644 index 0000000000..7a5686c736 Binary files /dev/null and b/login/apps/login/screenshots/passkey.png differ diff --git a/login/apps/login/screenshots/password.png b/login/apps/login/screenshots/password.png new file mode 100644 index 0000000000..05cf8747bb Binary files /dev/null and b/login/apps/login/screenshots/password.png differ diff --git a/login/apps/login/screenshots/password_change.png b/login/apps/login/screenshots/password_change.png new file mode 100644 index 0000000000..183de6df34 Binary files /dev/null and b/login/apps/login/screenshots/password_change.png differ diff --git a/login/apps/login/screenshots/password_set.png b/login/apps/login/screenshots/password_set.png new file mode 100644 index 0000000000..15b5ff49ad Binary files /dev/null and b/login/apps/login/screenshots/password_set.png differ diff --git a/login/apps/login/screenshots/register.png b/login/apps/login/screenshots/register.png new file mode 100644 index 0000000000..ba9f6951d8 Binary files /dev/null and b/login/apps/login/screenshots/register.png differ diff --git a/login/apps/login/screenshots/register_password.png b/login/apps/login/screenshots/register_password.png new file mode 100644 index 0000000000..31515bda9a Binary files /dev/null and b/login/apps/login/screenshots/register_password.png differ diff --git a/login/apps/login/screenshots/signedin.png b/login/apps/login/screenshots/signedin.png new file mode 100644 index 0000000000..f96ea1721f Binary files /dev/null and b/login/apps/login/screenshots/signedin.png differ diff --git a/login/apps/login/screenshots/u2f.png b/login/apps/login/screenshots/u2f.png new file mode 100644 index 0000000000..6b8eca087d Binary files /dev/null and b/login/apps/login/screenshots/u2f.png differ diff --git a/login/apps/login/screenshots/u2fset.png b/login/apps/login/screenshots/u2fset.png new file mode 100644 index 0000000000..37115548a5 Binary files /dev/null and b/login/apps/login/screenshots/u2fset.png differ diff --git a/login/apps/login/screenshots/verify.png b/login/apps/login/screenshots/verify.png new file mode 100644 index 0000000000..c13e6a3a88 Binary files /dev/null and b/login/apps/login/screenshots/verify.png differ diff --git a/login/apps/login/src/app/(login)/accounts/page.tsx b/login/apps/login/src/app/(login)/accounts/page.tsx new file mode 100644 index 0000000000..a1e99401e2 --- /dev/null +++ b/login/apps/login/src/app/(login)/accounts/page.tsx @@ -0,0 +1,97 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SessionsList } from "@/components/sessions-list"; +import { Translated } from "@/components/translated"; +import { getAllSessionCookieIds } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getBrandingSettings, + getDefaultOrg, + listSessions, +} from "@/lib/zitadel"; +import { UserPlusIcon } from "@heroicons/react/24/outline"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale } from "next-intl/server"; +import { headers } from "next/headers"; +import Link from "next/link"; + +async function loadSessions({ serviceUrl }: { serviceUrl: string }) { + const ids: (string | undefined)[] = await getAllSessionCookieIds(); + + if (ids && ids.length) { + const response = await listSessions({ + serviceUrl, + ids: ids.filter((id) => !!id) as string[], + }); + return response?.sessions ?? []; + } else { + console.info("No session cookie found."); + return []; + } +} + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + + const requestId = searchParams?.requestId; + const organization = searchParams?.organization; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + let sessions = await loadSessions({ serviceUrl }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + const params = new URLSearchParams(); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization) { + params.append("organization", organization); + } + + return ( + +
+

+ +

+

+ +

+ +
+ + +
+
+ +
+ + + +
+ +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/authenticator/set/page.tsx b/login/apps/login/src/app/(login)/authenticator/set/page.tsx new file mode 100644 index 0000000000..a339426c89 --- /dev/null +++ b/login/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -0,0 +1,218 @@ +import { Alert } from "@/components/alert"; +import { BackButton } from "@/components/back-button"; +import { ChooseAuthenticatorToSetup } from "@/components/choose-authenticator-to-setup"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { checkUserVerification } from "@/lib/verify-helper"; +import { + getActiveIdentityProviders, + getBrandingSettings, + getLoginSettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { getLocale } from "next-intl/server"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + + const { loginName, requestId, organization, sessionId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionWithData = sessionId + ? await loadSessionById(sessionId, organization) + : await loadSessionByLoginname(loginName, organization); + + async function getAuthMethodsAndUser( + serviceUrl: string, + + session?: Session, + ) { + const userId = session?.factors?.user?.id; + + if (!userId) { + throw Error("Could not get user id from session"); + } + + return listAuthenticationMethodTypes({ + serviceUrl, + userId, + }).then((methods) => { + return getUserByID({ serviceUrl, userId }).then((user) => { + const humanUser = + user.user?.type.case === "human" ? user.user?.type.value : undefined; + + return { + factors: session?.factors, + authMethods: methods.authMethodTypes ?? [], + phoneVerified: humanUser?.phone?.isVerified ?? false, + emailVerified: humanUser?.email?.isVerified ?? false, + expirationDate: session?.expirationDate, + }; + }); + }); + } + + async function loadSessionByLoginname( + loginName?: string, + organization?: string, + ) { + return loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }).then((session) => { + return getAuthMethodsAndUser(serviceUrl, session); + }); + } + + async function loadSessionById(sessionId: string, organization?: string) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((sessionResponse) => { + return getAuthMethodsAndUser(serviceUrl, sessionResponse.session); + }); + } + + if ( + !sessionWithData || + !sessionWithData.factors || + !sessionWithData.factors.user + ) { + return ( + + + + ); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: sessionWithData.factors.user?.organizationId, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: sessionWithData.factors.user?.organizationId, + }); + + // check if user was verified recently + const isUserVerified = await checkUserVerification( + sessionWithData.factors.user?.id, + ); + + if (!isUserVerified) { + const params = new URLSearchParams({ + loginName: sessionWithData.factors.user.loginName as string, + invite: "true", + send: "true", // set this to true to request a new code immediately + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || sessionWithData.factors.user.organizationId) { + params.append( + "organization", + organization ?? (sessionWithData.factors.user.organizationId as string), + ); + } + + redirect(`/verify?` + params); + } + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: sessionWithData.factors?.user?.organizationId, + linking_allowed: true, + }).then((resp) => { + return resp.identityProviders; + }); + + const params = new URLSearchParams({ + initial: "true", // defines that a code is not required and is therefore not shown in the UI + }); + + if (sessionWithData.factors?.user?.loginName) { + params.set("loginName", sessionWithData.factors?.user?.loginName); + } + + if (sessionWithData.factors?.user?.organizationId) { + params.set("organization", sessionWithData.factors?.user?.organizationId); + } + + if (requestId) { + params.set("requestId", requestId); + } + + return ( + +
+

+ +

+ +

+ +

+ + + + {loginSettings && ( + + )} + + {loginSettings?.allowExternalIdp && !!identityProviders.length && ( + <> +
+

+ +

+
+ + + + )} + +
+ + +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/device/consent/page.tsx b/login/apps/login/src/app/(login)/device/consent/page.tsx new file mode 100644 index 0000000000..9f257bca8c --- /dev/null +++ b/login/apps/login/src/app/(login)/device/consent/page.tsx @@ -0,0 +1,99 @@ +import { ConsentScreen } from "@/components/consent"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getBrandingSettings, + getDefaultOrg, + getDeviceAuthorizationRequest, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + const userCode = searchParams?.user_code; + const requestId = searchParams?.requestId; + const organization = searchParams?.organization; + + if (!userCode || !requestId) { + return ( +
+ +
+ ); + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { deviceAuthorizationRequest } = await getDeviceAuthorizationRequest({ + serviceUrl, + userCode, + }); + + if (!deviceAuthorizationRequest) { + return ( +
+ +
+ ); + } + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + const params = new URLSearchParams(); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization) { + params.append("organization", organization); + } + + return ( + +
+

+ +

+ +

+ +

+ + +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/device/page.tsx b/login/apps/login/src/app/(login)/device/page.tsx new file mode 100644 index 0000000000..e8761d25de --- /dev/null +++ b/login/apps/login/src/app/(login)/device/page.tsx @@ -0,0 +1,48 @@ +import { DeviceCodeForm } from "@/components/device-code-form"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + const userCode = searchParams?.user_code; + const organization = searchParams?.organization; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+

+ +

+

+ +

+ +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/error.tsx b/login/apps/login/src/app/(login)/error.tsx new file mode 100644 index 0000000000..d14150a4b4 --- /dev/null +++ b/login/apps/login/src/app/(login)/error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Boundary } from "@/components/boundary"; +import { Button } from "@/components/button"; +import { Translated } from "@/components/translated"; +import { useEffect } from "react"; + +export default function Error({ error, reset }: any) { + useEffect(() => { + console.log("logging error:", error); + }, [error]); + + return ( + +
+
+ Error: {error?.message} +
+
+ +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx b/login/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx new file mode 100644 index 0000000000..f2b7a19b91 --- /dev/null +++ b/login/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx @@ -0,0 +1,105 @@ +import { Alert, AlertType } from "@/components/alert"; +import { ChooseAuthenticatorToLogin } from "@/components/choose-authenticator-to-login"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getBrandingSettings, + getLoginSettings, + getUserByID, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; + params: Promise<{ provider: string }>; +}) { + const searchParams = await props.searchParams; + + const { organization, userId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + let authMethods: AuthenticationMethodType[] = []; + let user: User | undefined = undefined; + let human: HumanUser | undefined = undefined; + + const params = new URLSearchParams({}); + if (organization) { + params.set("organization", organization); + } + if (userId) { + params.set("userId", userId); + } + + if (userId) { + const userResponse = await getUserByID({ + serviceUrl, + userId, + }); + if (userResponse) { + user = userResponse.user; + if (user?.type.case === "human") { + human = user.type.value as HumanUser; + } + + if (user?.preferredLoginName) { + params.set("loginName", user.preferredLoginName); + } + } + + const authMethodsResponse = await listAuthenticationMethodTypes({ + serviceUrl, + userId, + }); + if (authMethodsResponse.authMethodTypes) { + authMethods = authMethodsResponse.authMethodTypes; + } + } + + return ( + +
+

+ +

+ + + + + {userId && authMethods.length && ( + <> + {user && human && ( + + )} + + + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/idp/[provider]/success/page.tsx b/login/apps/login/src/app/(login)/idp/[provider]/success/page.tsx new file mode 100644 index 0000000000..ae9feff6b7 --- /dev/null +++ b/login/apps/login/src/app/(login)/idp/[provider]/success/page.tsx @@ -0,0 +1,340 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { IdpSignin } from "@/components/idp-signin"; +import { completeIDP } from "@/components/idps/pages/complete-idp"; +import { linkingFailed } from "@/components/idps/pages/linking-failed"; +import { linkingSuccess } from "@/components/idps/pages/linking-success"; +import { loginFailed } from "@/components/idps/pages/login-failed"; +import { loginSuccess } from "@/components/idps/pages/login-success"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + addHuman, + addIDPLink, + getBrandingSettings, + getDefaultOrg, + getIDPByID, + getLoginSettings, + getOrgsByDomain, + listUsers, + retrieveIDPIntent, + updateHuman, +} from "@/lib/zitadel"; +import { ConnectError, create } from "@zitadel/client"; +import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; +import { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { + AddHumanUserRequest, + AddHumanUserRequestSchema, + UpdateHumanUserRequestSchema, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; + +const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; + +async function resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, +}: { + organization?: string; + addHumanUser?: { username?: string }; + serviceUrl: string; +}): Promise { + if (organization) return organization; + + if (addHumanUser?.username && ORG_SUFFIX_REGEX.test(addHumanUser.username)) { + const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username); + const suffix = matched?.[1] ?? ""; + + const orgs = await getOrgsByDomain({ + serviceUrl, + domain: suffix, + }); + const orgToCheckForDiscovery = + orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + + if (orgToCheckForDiscovery) { + const orgLoginSettings = await getLoginSettings({ + serviceUrl, + organization: orgToCheckForDiscovery, + }); + if (orgLoginSettings?.allowDomainDiscovery) { + return orgToCheckForDiscovery; + } + } + } + return undefined; +} + +export default async function Page(props: { + searchParams: Promise>; + params: Promise<{ provider: string }>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + let { id, token, requestId, organization, link } = searchParams; + const { provider } = params; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + organization = org.id; + } + } + + if (!provider || !id || !token) { + return loginFailed(branding, "IDP context missing"); + } + + const intent = await retrieveIDPIntent({ + serviceUrl, + id, + token, + }); + + const { idpInformation, userId } = intent; + let { addHumanUser } = intent; + + if (!idpInformation) { + return loginFailed(branding, "IDP information missing"); + } + + const idp = await getIDPByID({ + serviceUrl, + id: idpInformation.idpId, + }); + + const options = idp?.config?.options; + + if (!idp) { + throw new Error("IDP not found"); + } + + // sign in user. If user should be linked continue + if (userId && !link) { + // if auto update is enabled, we will update the user with the new information + if (options?.isAutoUpdate && addHumanUser) { + try { + await updateHuman({ + serviceUrl, + request: create(UpdateHumanUserRequestSchema, { + userId: userId, + profile: addHumanUser.profile, + email: addHumanUser.email, + phone: addHumanUser.phone, + }), + }); + } catch (error: unknown) { + // Log the error and continue with the login process + console.warn("An error occurred while updating the user:", error); + } + } + + return loginSuccess( + userId, + { idpIntentId: id, idpIntentToken: token }, + requestId, + branding, + ); + } + + if (link) { + if (!options?.isLinkingAllowed) { + // linking was probably disallowed since the invitation was created + return linkingFailed(branding, "Linking is no longer allowed"); + } + + let idpLink; + try { + idpLink = await addIDPLink({ + serviceUrl, + idp: { + id: idpInformation.idpId, + userId: idpInformation.userId, + userName: idpInformation.userName, + }, + userId, + }); + } catch (error) { + console.error(error); + return linkingFailed(branding); + } + + if (!idpLink) { + return linkingFailed(branding); + } else { + return linkingSuccess( + userId, + { idpIntentId: id, idpIntentToken: token }, + requestId, + branding, + ); + } + } + + // search for potential user via username, then link + if (options?.autoLinking) { + let foundUser; + const email = addHumanUser?.email?.email; + + if (options.autoLinking === AutoLinkingOption.EMAIL && email) { + foundUser = await listUsers({ serviceUrl, email }).then((response) => { + return response.result ? response.result[0] : null; + }); + } else if (options.autoLinking === AutoLinkingOption.USERNAME) { + foundUser = await listUsers( + options.autoLinking === AutoLinkingOption.USERNAME + ? { serviceUrl, userName: idpInformation.userName } + : { serviceUrl, email }, + ).then((response) => { + return response.result ? response.result[0] : null; + }); + } else { + foundUser = await listUsers({ + serviceUrl, + userName: idpInformation.userName, + email, + }).then((response) => { + return response.result ? response.result[0] : null; + }); + } + + if (foundUser) { + let idpLink; + try { + idpLink = await addIDPLink({ + serviceUrl, + idp: { + id: idpInformation.idpId, + userId: idpInformation.userId, + userName: idpInformation.userName, + }, + userId: foundUser.userId, + }); + } catch (error) { + console.error(error); + return linkingFailed(branding); + } + + if (!idpLink) { + return linkingFailed(branding); + } else { + return linkingSuccess( + foundUser.userId, + { idpIntentId: id, idpIntentToken: token }, + requestId, + branding, + ); + } + } + } + + let newUser; + // automatic creation of a user is allowed and data is complete + if (options?.isAutoCreation && addHumanUser) { + const orgToRegisterOn = await resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, + }); + + let addHumanUserWithOrganization: AddHumanUserRequest; + if (orgToRegisterOn) { + const organizationSchema = create(OrganizationSchema, { + org: { case: "orgId", value: orgToRegisterOn }, + }); + + addHumanUserWithOrganization = create(AddHumanUserRequestSchema, { + ...addHumanUser, + organization: organizationSchema, + }); + } else { + addHumanUserWithOrganization = create( + AddHumanUserRequestSchema, + addHumanUser, + ); + } + + try { + newUser = await addHuman({ + serviceUrl, + request: addHumanUserWithOrganization, + }); + } catch (error: unknown) { + console.error( + "An error occurred while creating the user:", + error, + addHumanUser, + ); + return loginFailed( + branding, + (error as ConnectError).message + ? (error as ConnectError).message + : "Could not create user", + ); + } + } else if (options?.isCreationAllowed) { + // if no user was found, we will create a new user manually / redirect to the registration page + const orgToRegisterOn = await resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, + }); + + if (orgToRegisterOn) { + branding = await getBrandingSettings({ + serviceUrl, + organization: orgToRegisterOn, + }); + } + + if (!orgToRegisterOn) { + return loginFailed(branding, "No organization found for registration"); + } + + return completeIDP({ + branding, + idpIntent: { idpIntentId: id, idpIntentToken: token }, + addHumanUser, + organization: orgToRegisterOn, + requestId, + idpUserId: idpInformation?.userId, + idpId: idpInformation?.idpId, + idpUserName: idpInformation?.userName, + }); + } + + if (newUser) { + return ( + +
+

+ +

+

+ +

+ +
+
+ ); + } + + // return login failed if no linking or creation is allowed and no user was found + return loginFailed(branding, "No user found"); +} diff --git a/login/apps/login/src/app/(login)/idp/ldap/page.tsx b/login/apps/login/src/app/(login)/idp/ldap/page.tsx new file mode 100644 index 0000000000..372c814525 --- /dev/null +++ b/login/apps/login/src/app/(login)/idp/ldap/page.tsx @@ -0,0 +1,56 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { LDAPUsernamePasswordForm } from "@/components/ldap-username-password-form"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; + params: Promise<{ provider: string }>; +}) { + const searchParams = await props.searchParams; + const { idpId, organization, link } = searchParams; + + if (!idpId) { + throw new Error("No idpId provided in searchParams"); + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + // return login failed if no linking or creation is allowed and no user was found + return ( + +
+

+ +

+

+ +

+ + +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/idp/page.tsx b/login/apps/login/src/app/(login)/idp/page.tsx new file mode 100644 index 0000000000..ab16e897e5 --- /dev/null +++ b/login/apps/login/src/app/(login)/idp/page.tsx @@ -0,0 +1,51 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getActiveIdentityProviders, getBrandingSettings } from "@/lib/zitadel"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + const requestId = searchParams?.requestId; + const organization = searchParams?.organization; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: organization, + }).then((resp) => { + return resp.identityProviders; + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

+ +

+

+ +

+ + {identityProviders && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/layout.tsx b/login/apps/login/src/app/(login)/layout.tsx new file mode 100644 index 0000000000..936c7e17e4 --- /dev/null +++ b/login/apps/login/src/app/(login)/layout.tsx @@ -0,0 +1,62 @@ +import "@/styles/globals.scss"; + +import { LanguageProvider } from "@/components/language-provider"; +import { LanguageSwitcher } from "@/components/language-switcher"; +import { Skeleton } from "@/components/skeleton"; +import { Theme } from "@/components/theme"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Analytics } from "@vercel/analytics/react"; +import { Lato } from "next/font/google"; +import { ReactNode, Suspense } from "react"; + +const lato = Lato({ + weight: ["400", "700", "900"], + subsets: ["latin"], +}); + +export default async function RootLayout({ + children, +}: { + children: ReactNode; +}) { + return ( + + + + + +
+ +
+
+
+ +
+
+ + } + > + +
+
+ {children} +
+ + +
+
+
+
+
+
+ + + + ); +} diff --git a/login/apps/login/src/app/(login)/loginname/page.tsx b/login/apps/login/src/app/(login)/loginname/page.tsx new file mode 100644 index 0000000000..f15f440930 --- /dev/null +++ b/login/apps/login/src/app/(login)/loginname/page.tsx @@ -0,0 +1,93 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { Translated } from "@/components/translated"; +import { UsernameForm } from "@/components/username-form"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getActiveIdentityProviders, + getBrandingSettings, + getDefaultOrg, + getLoginSettings, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + const loginName = searchParams?.loginName; + const requestId = searchParams?.requestId; + const organization = searchParams?.organization; + const suffix = searchParams?.suffix; + const submit: boolean = searchParams?.submit === "true"; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + const contextLoginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: organization ?? defaultOrganization, + }).then((resp) => { + return resp.identityProviders; + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+

+ +

+

+ +

+ + + + {identityProviders && loginSettings?.allowExternalIdp && ( +
+ +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/logout/page.tsx b/login/apps/login/src/app/(login)/logout/page.tsx new file mode 100644 index 0000000000..ca97b37b20 --- /dev/null +++ b/login/apps/login/src/app/(login)/logout/page.tsx @@ -0,0 +1,86 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SessionsClearList } from "@/components/sessions-clear-list"; +import { Translated } from "@/components/translated"; +import { getAllSessionCookieIds } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getBrandingSettings, + getDefaultOrg, + listSessions, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { headers } from "next/headers"; + +async function loadSessions({ serviceUrl }: { serviceUrl: string }) { + const ids: (string | undefined)[] = await getAllSessionCookieIds(); + + if (ids && ids.length) { + const response = await listSessions({ + serviceUrl, + ids: ids.filter((id) => !!id) as string[], + }); + return response?.sessions ?? []; + } else { + console.info("No session cookie found."); + return []; + } +} + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + const organization = searchParams?.organization; + const postLogoutRedirectUri = searchParams?.post_logout_redirect_uri; + const logoutHint = searchParams?.logout_hint; + const UILocales = searchParams?.ui_locales; // TODO implement with new translation service + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + let sessions = await loadSessions({ serviceUrl }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + const params = new URLSearchParams(); + + if (organization) { + params.append("organization", organization); + } + + return ( + +
+

+ +

+

+ +

+ +
+ +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/logout/success/page.tsx b/login/apps/login/src/app/(login)/logout/success/page.tsx new file mode 100644 index 0000000000..e7ec459f03 --- /dev/null +++ b/login/apps/login/src/app/(login)/logout/success/page.tsx @@ -0,0 +1,43 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { login_hint, organization } = searchParams; + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

+ +

+

+ +

+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/mfa/page.tsx b/login/apps/login/src/app/(login)/mfa/page.tsx new file mode 100644 index 0000000000..5543cdf66f --- /dev/null +++ b/login/apps/login/src/app/(login)/mfa/page.tsx @@ -0,0 +1,134 @@ +import { Alert } from "@/components/alert"; +import { BackButton } from "@/components/back-button"; +import { ChooseSecondFactor } from "@/components/choose-second-factor"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getSession, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + const { loginName, requestId, organization, sessionId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionFactors = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadSessionByLoginname(serviceUrl, loginName, organization); + + async function loadSessionByLoginname( + serviceUrl: string, + loginName?: string, + organization?: string, + ) { + return loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }).then((session) => { + if (session && session.factors?.user?.id) { + return listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }).then((methods) => { + return { + factors: session?.factors, + authMethods: methods.authMethodTypes ?? [], + }; + }); + } + }); + } + + async function loadSessionById( + host: string, + sessionId: string, + organization?: string, + ) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session && response.session.factors?.user?.id) { + return listAuthenticationMethodTypes({ + serviceUrl, + userId: response.session.factors.user.id, + }).then((methods) => { + return { + factors: response.session?.factors, + authMethods: methods.authMethodTypes ?? [], + }; + }); + } + }); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

+ +

+ +

+ +

+ + {sessionFactors && ( + + )} + + {!(loginName || sessionId) && ( + + + + )} + + {sessionFactors ? ( + + ) : ( + + + + )} + +
+ + +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/mfa/set/page.tsx b/login/apps/login/src/app/(login)/mfa/set/page.tsx new file mode 100644 index 0000000000..ebfa358d6d --- /dev/null +++ b/login/apps/login/src/app/(login)/mfa/set/page.tsx @@ -0,0 +1,174 @@ +import { Alert } from "@/components/alert"; +import { BackButton } from "@/components/back-button"; +import { ChooseSecondFactorToSetup } from "@/components/choose-second-factor-to-setup"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { Timestamp, timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { headers } from "next/headers"; + +function isSessionValid(session: Partial): { + valid: boolean; + verifiedAt?: Timestamp; +} { + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate) > new Date() + : true; + + const verifiedAt = validPassword || validPasskey; + const valid = !!((validPassword || validPasskey) && stillValid); + + return { valid, verifiedAt }; +} + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + const { loginName, checkAfter, force, requestId, organization, sessionId } = + searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionWithData = sessionId + ? await loadSessionById(sessionId, organization) + : await loadSessionByLoginname(loginName, organization); + + async function getAuthMethodsAndUser(session?: Session) { + const userId = session?.factors?.user?.id; + + if (!userId) { + throw Error("Could not get user id from session"); + } + + return listAuthenticationMethodTypes({ + serviceUrl, + userId, + }).then((methods) => { + return getUserByID({ serviceUrl, userId }).then((user) => { + const humanUser = + user.user?.type.case === "human" ? user.user?.type.value : undefined; + + return { + id: session.id, + factors: session?.factors, + authMethods: methods.authMethodTypes ?? [], + phoneVerified: humanUser?.phone?.isVerified ?? false, + emailVerified: humanUser?.email?.isVerified ?? false, + expirationDate: session?.expirationDate, + }; + }); + }); + } + + async function loadSessionByLoginname( + loginName?: string, + organization?: string, + ) { + return loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }).then((session) => { + return getAuthMethodsAndUser(session); + }); + } + + async function loadSessionById(sessionId: string, organization?: string) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((sessionResponse) => { + return getAuthMethodsAndUser(sessionResponse.session); + }); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: sessionWithData.factors?.user?.organizationId, + }); + + const { valid } = isSessionValid(sessionWithData); + + return ( + +
+

+ +

+ +

+ +

+ + {sessionWithData && ( + + )} + + {!(loginName || sessionId) && ( + + + + )} + + {!valid && ( + + + + )} + + {isSessionValid(sessionWithData).valid && + loginSettings && + sessionWithData && + sessionWithData.factors?.user?.id && ( + + )} + +
+ + +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/otp/[method]/page.tsx b/login/apps/login/src/app/(login)/otp/[method]/page.tsx new file mode 100644 index 0000000000..2d9daac64f --- /dev/null +++ b/login/apps/login/src/app/(login)/otp/[method]/page.tsx @@ -0,0 +1,136 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { LoginOTP } from "@/components/login-otp"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getSession, +} from "@/lib/zitadel"; +import { getLocale } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; + params: Promise>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + const locale = getLocale(); + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const { + loginName, // send from password page + userId, // send from email link + requestId, + sessionId, + organization, + code, + submit, + } = searchParams; + + const { method } = params; + + const session = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }); + + async function loadSessionById( + host: string, + sessionId: string, + organization?: string, + ) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + } + + // email links do not come with organization, thus we need to use the session's organization + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? session?.factors?.user?.organizationId, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization ?? session?.factors?.user?.organizationId, + }); + + return ( + +
+

+ +

+ {method === "time-based" && ( +

+ +

+ )} + {method === "sms" && ( +

+ +

+ )} + {method === "email" && ( +

+ +

+ )} + + {!session && ( +
+ + + +
+ )} + + {session && ( + + )} + + {method && session && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/otp/[method]/set/page.tsx b/login/apps/login/src/app/(login)/otp/[method]/set/page.tsx new file mode 100644 index 0000000000..f74093ce8e --- /dev/null +++ b/login/apps/login/src/app/(login)/otp/[method]/set/page.tsx @@ -0,0 +1,204 @@ +import { Alert } from "@/components/alert"; +import { BackButton } from "@/components/back-button"; +import { Button, ButtonVariants } from "@/components/button"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { TotpRegister } from "@/components/totp-register"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + addOTPEmail, + addOTPSMS, + getBrandingSettings, + getLoginSettings, + registerTOTP, +} from "@/lib/zitadel"; +import { RegisterTOTPResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +export default async function Page(props: { + searchParams: Promise>; + params: Promise>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + + const { loginName, organization, sessionId, requestId, checkAfter } = + searchParams; + const { method } = params; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const session = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + let totpResponse: RegisterTOTPResponse | undefined, error: Error | undefined; + if (session && session.factors?.user?.id) { + if (method === "time-based") { + await registerTOTP({ + serviceUrl, + userId: session.factors.user.id, + }) + .then((resp) => { + if (resp) { + totpResponse = resp; + } + }) + .catch((err) => { + error = err; + }); + } else if (method === "sms") { + // does not work + await addOTPSMS({ + serviceUrl, + userId: session.factors.user.id, + }).catch((error) => { + error = new Error("Could not add OTP via SMS"); + }); + } else if (method === "email") { + // works + await addOTPEmail({ + serviceUrl, + userId: session.factors.user.id, + }).catch((error) => { + error = new Error("Could not add OTP via Email"); + }); + } else { + throw new Error("Invalid method"); + } + } else { + throw new Error("No session found"); + } + + const paramsToContinue = new URLSearchParams({}); + let urlToContinue = "/accounts"; + + if (sessionId) { + paramsToContinue.append("sessionId", sessionId); + } + if (loginName) { + paramsToContinue.append("loginName", loginName); + } + if (organization) { + paramsToContinue.append("organization", organization); + } + + if (checkAfter) { + if (requestId) { + paramsToContinue.append("requestId", requestId); + } + urlToContinue = `/otp/${method}?` + paramsToContinue; + // immediately check the OTP on the next page if sms or email was set up + if (["email", "sms"].includes(method)) { + return redirect(urlToContinue); + } + } else if (requestId && sessionId) { + if (requestId) { + paramsToContinue.append("authRequest", requestId); + } + urlToContinue = `/login?` + paramsToContinue; + } else if (loginName) { + if (requestId) { + paramsToContinue.append("requestId", requestId); + } + urlToContinue = `/signedin?` + paramsToContinue; + } + + return ( + +
+

+ +

+ {!session && ( +
+ + + +
+ )} + + {error && ( +
+ {error?.message} +
+ )} + + {session && ( + + )} + + {totpResponse && "uri" in totpResponse && "secret" in totpResponse ? ( + <> +

+ +

+
+ +
{" "} + + ) : ( + <> +

+ {method === "email" + ? "Code via email was successfully added." + : method === "sms" + ? "Code via SMS was successfully added." + : ""} +

+ +
+ + + + + + +
+ + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/page.tsx b/login/apps/login/src/app/(login)/page.tsx new file mode 100644 index 0000000000..f1fce50f90 --- /dev/null +++ b/login/apps/login/src/app/(login)/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export default function Page() { + // automatically redirect to loginname + if (process.env.DEBUG !== "true") { + redirect("/loginname"); + } +} diff --git a/login/apps/login/src/app/(login)/passkey/page.tsx b/login/apps/login/src/app/(login)/passkey/page.tsx new file mode 100644 index 0000000000..bef71986f3 --- /dev/null +++ b/login/apps/login/src/app/(login)/passkey/page.tsx @@ -0,0 +1,89 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { LoginPasskey } from "@/components/login-passkey"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings, getSession } from "@/lib/zitadel"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + const { loginName, altPassword, requestId, organization, sessionId } = + searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionFactors = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }); + + async function loadSessionById( + serviceUrl: string, + sessionId: string, + organization?: string, + ) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

+ +

+ + {sessionFactors && ( + + )} +

+ +

+ + {!(loginName || sessionId) && ( + + + + )} + + {(loginName || sessionId) && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/passkey/set/page.tsx b/login/apps/login/src/app/(login)/passkey/set/page.tsx new file mode 100644 index 0000000000..3a3dccf8d7 --- /dev/null +++ b/login/apps/login/src/app/(login)/passkey/set/page.tsx @@ -0,0 +1,85 @@ +import { Alert, AlertType } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { RegisterPasskey } from "@/components/register-passkey"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings } from "@/lib/zitadel"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + const { loginName, prompt, organization, requestId, userId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const session = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

+ +

+ + {session && ( + + )} +

+ +

+ + + + + + + + + + + {!session && ( +
+ + + +
+ )} + + {session?.id && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/password/change/page.tsx b/login/apps/login/src/app/(login)/password/change/page.tsx new file mode 100644 index 0000000000..78ba88d282 --- /dev/null +++ b/login/apps/login/src/app/(login)/password/change/page.tsx @@ -0,0 +1,100 @@ +import { Alert } from "@/components/alert"; +import { ChangePasswordForm } from "@/components/change-password-form"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getPasswordComplexitySettings, +} from "@/lib/zitadel"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const searchParams = await props.searchParams; + + const { loginName, organization, requestId } = searchParams; + + // also allow no session to be found (ignoreUnkownUsername) + const sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const passwordComplexity = await getPasswordComplexitySettings({ + serviceUrl, + organization: sessionFactors?.factors?.user?.organizationId, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: sessionFactors?.factors?.user?.organizationId, + }); + + return ( + +
+

+ {sessionFactors?.factors?.user?.displayName ?? ( + + )} +

+

+ +

+ + {/* show error only if usernames should be shown to be unknown */} + {(!sessionFactors || !loginName) && + !loginSettings?.ignoreUnknownUsernames && ( +
+ + + +
+ )} + + {sessionFactors && ( + + )} + + {passwordComplexity && + loginName && + sessionFactors?.factors?.user?.id ? ( + + ) : ( +
+ + + +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/password/page.tsx b/login/apps/login/src/app/(login)/password/page.tsx new file mode 100644 index 0000000000..461c095157 --- /dev/null +++ b/login/apps/login/src/app/(login)/password/page.tsx @@ -0,0 +1,102 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { PasswordForm } from "@/components/password-form"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getDefaultOrg, + getLoginSettings, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + let { loginName, organization, requestId, alt } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + + if (org) { + defaultOrganization = org.id; + } + } + + // also allow no session to be found (ignoreUnkownUsername) + let sessionFactors; + try { + sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + } catch (error) { + // ignore error to continue to show the password form + console.warn(error); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+

+ {sessionFactors?.factors?.user?.displayName ?? ( + + )} +

+

+ +

+ + {/* show error only if usernames should be shown to be unknown */} + {(!sessionFactors || !loginName) && + !loginSettings?.ignoreUnknownUsernames && ( +
+ + + +
+ )} + + {sessionFactors && ( + + )} + + {loginName && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/password/set/page.tsx b/login/apps/login/src/app/(login)/password/set/page.tsx new file mode 100644 index 0000000000..b717fd5d96 --- /dev/null +++ b/login/apps/login/src/app/(login)/password/set/page.tsx @@ -0,0 +1,137 @@ +import { Alert, AlertType } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SetPasswordForm } from "@/components/set-password-form"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getPasswordComplexitySettings, + getUserByID, +} from "@/lib/zitadel"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getLocale } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + + const { userId, loginName, organization, requestId, code, initial } = + searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // also allow no session to be found (ignoreUnkownUsername) + let session: Session | undefined; + if (loginName) { + session = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const passwordComplexity = await getPasswordComplexitySettings({ + serviceUrl, + organization: session?.factors?.user?.organizationId, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + let user: User | undefined; + let displayName: string | undefined; + if (userId) { + const userResponse = await getUserByID({ + serviceUrl, + userId, + }); + user = userResponse.user; + + if (user?.type.case === "human") { + displayName = (user.type.value as HumanUser).profile?.displayName; + } + } + + return ( + +
+

+ {session?.factors?.user?.displayName ?? ( + + )} +

+

+ +

+ + {/* show error only if usernames should be shown to be unknown */} + {loginName && !session && !loginSettings?.ignoreUnknownUsernames && ( +
+ + + +
+ )} + + {session ? ( + + ) : user ? ( + + ) : null} + + {!initial && ( + + + + )} + + {passwordComplexity && + (loginName ?? user?.preferredLoginName) && + (userId ?? session?.factors?.user?.id) ? ( + + ) : ( +
+ + + +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/register/page.tsx b/login/apps/login/src/app/(login)/register/page.tsx new file mode 100644 index 0000000000..aa83ad1ead --- /dev/null +++ b/login/apps/login/src/app/(login)/register/page.tsx @@ -0,0 +1,136 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { RegisterForm } from "@/components/register-form"; +import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getActiveIdentityProviders, + getBrandingSettings, + getDefaultOrg, + getLegalAndSupportSettings, + getLoginSettings, + getPasswordComplexitySettings, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { getLocale } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + + let { firstname, lastname, email, organization, requestId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + organization = org.id; + } + } + + const legal = await getLegalAndSupportSettings({ + serviceUrl, + organization, + }); + const passwordComplexitySettings = await getPasswordComplexitySettings({ + serviceUrl, + organization, + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: organization, + }).then((resp) => { + return resp.identityProviders.filter((idp) => { + return idp.options?.isAutoCreation || idp.options?.isCreationAllowed; // check if IDP allows to create account automatically or manual creation is allowed + }); + }); + + if (!loginSettings?.allowRegister) { + return ( + +
+

+ +

+

+ +

+
+
+ ); + } + + return ( + +
+

+ +

+

+ +

+ + {!organization && ( + + + + )} + + {legal && + passwordComplexitySettings && + organization && + (loginSettings.allowUsernamePassword || + loginSettings.passkeysType == PasskeysType.ALLOWED) && ( + + )} + + {loginSettings?.allowExternalIdp && !!identityProviders.length && ( + <> +
+

+ +

+
+ + + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/register/password/page.tsx b/login/apps/login/src/app/(login)/register/password/page.tsx new file mode 100644 index 0000000000..e9689f0f5e --- /dev/null +++ b/login/apps/login/src/app/(login)/register/password/page.tsx @@ -0,0 +1,100 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SetRegisterPasswordForm } from "@/components/set-register-password-form"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getBrandingSettings, + getDefaultOrg, + getLegalAndSupportSettings, + getLoginSettings, + getPasswordComplexitySettings, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + let { firstname, lastname, email, organization, requestId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + organization = org.id; + } + } + + const missingData = !firstname || !lastname || !email || !organization; + + const legal = await getLegalAndSupportSettings({ + serviceUrl, + organization, + }); + const passwordComplexitySettings = await getPasswordComplexitySettings({ + serviceUrl, + organization, + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + return missingData ? ( + +
+

+ +

+

+ +

+
+
+ ) : loginSettings?.allowRegister && loginSettings.allowUsernamePassword ? ( + +
+

+ +

+

+ +

+ + {legal && passwordComplexitySettings && ( + + )} +
+
+ ) : ( + +
+

+ +

+

+ +

+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/saml-post/route.ts b/login/apps/login/src/app/(login)/saml-post/route.ts new file mode 100644 index 0000000000..a2061a18e2 --- /dev/null +++ b/login/apps/login/src/app/(login)/saml-post/route.ts @@ -0,0 +1,49 @@ +import { getSAMLFormCookie } from "@/lib/saml"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const url = searchParams.get("url"); + const id = searchParams.get("id"); + + if (!url) { + return new NextResponse("Missing url parameter", { status: 400 }); + } + + if (!id) { + return new NextResponse("Missing id parameter", { status: 400 }); + } + + const formData = await getSAMLFormCookie(id); + + const formDataParsed = formData ? JSON.parse(formData) : null; + + if (!formDataParsed) { + return new NextResponse("SAML form data not found", { status: 404 }); + } + + // Generate hidden input fields for all key-value pairs in formDataParsed + const hiddenInputs = Object.entries(formDataParsed) + .map( + ([key, value]) => + ``, + ) + .join("\n "); + + // Respond with an HTML form that auto-submits via POST + const html = ` + + +
+ ${hiddenInputs} + +
+ + + `; + return new NextResponse(html, { + headers: { "Content-Type": "text/html" }, + }); +} diff --git a/login/apps/login/src/app/(login)/signedin/page.tsx b/login/apps/login/src/app/(login)/signedin/page.tsx new file mode 100644 index 0000000000..5b2ed5fbf4 --- /dev/null +++ b/login/apps/login/src/app/(login)/signedin/page.tsx @@ -0,0 +1,141 @@ +import { Alert, AlertType } from "@/components/alert"; +import { Button, ButtonVariants } from "@/components/button"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { + getMostRecentCookieWithLoginname, + getSessionCookieById, +} from "@/lib/cookies"; +import { completeDeviceAuthorization } from "@/lib/server/device"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getSession, +} from "@/lib/zitadel"; +import { headers } from "next/headers"; +import Link from "next/link"; + +async function loadSessionById( + serviceUrl: string, + sessionId: string, + organization?: string, +) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); +} + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { loginName, requestId, organization, sessionId } = searchParams; + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + // complete device authorization flow if device requestId is present + if (requestId && requestId.startsWith("device_")) { + const cookie = sessionId + ? await getSessionCookieById({ sessionId, organization }) + : await getMostRecentCookieWithLoginname({ + loginName: loginName, + organization: organization, + }); + + await completeDeviceAuthorization(requestId.replace("device_", ""), { + sessionId: cookie.id, + sessionToken: cookie.token, + }).catch((err) => { + return ( + +
+

+ +

+

+ +

+ {err.message} +
+
+ ); + }); + } + + const sessionFactors = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }); + + let loginSettings; + if (!requestId) { + loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + } + + return ( + +
+

+ +

+

+ +

+ + + + {requestId && requestId.startsWith("device_") && ( + + You can now close this window and return to the device where you + started the authorization process to continue. + + )} + + {loginSettings?.defaultRedirectUri && ( +
+ + + + + +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/u2f/page.tsx b/login/apps/login/src/app/(login)/u2f/page.tsx new file mode 100644 index 0000000000..7fba7be1be --- /dev/null +++ b/login/apps/login/src/app/(login)/u2f/page.tsx @@ -0,0 +1,96 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { LoginPasskey } from "@/components/login-passkey"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings, getSession } from "@/lib/zitadel"; +import { getLocale } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + + const { loginName, requestId, sessionId, organization } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const sessionFactors = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }); + + async function loadSessionById( + host: string, + sessionId: string, + organization?: string, + ) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + } + + return ( + +
+

+ +

+ + {sessionFactors && ( + + )} +

+ +

+ + {!(loginName || sessionId) && ( + + + + )} + + {(loginName || sessionId) && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/u2f/set/page.tsx b/login/apps/login/src/app/(login)/u2f/set/page.tsx new file mode 100644 index 0000000000..b73e902821 --- /dev/null +++ b/login/apps/login/src/app/(login)/u2f/set/page.tsx @@ -0,0 +1,76 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { RegisterU2f } from "@/components/register-u2f"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings } from "@/lib/zitadel"; +import { getLocale } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + + const { loginName, organization, requestId, checkAfter } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

+ +

+ + {sessionFactors && ( + + )} +

+ {" "} + +

+ + {!sessionFactors && ( +
+ + + +
+ )} + + {sessionFactors?.id && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/verify/page.tsx b/login/apps/login/src/app/(login)/verify/page.tsx new file mode 100644 index 0000000000..a61d4e608c --- /dev/null +++ b/login/apps/login/src/app/(login)/verify/page.tsx @@ -0,0 +1,174 @@ +import { Alert, AlertType } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { VerifyForm } from "@/components/verify-form"; +import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings, getUserByID } from "@/lib/zitadel"; +import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getLocale } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + const locale = getLocale(); + + const { userId, loginName, code, organization, requestId, invite, send } = + searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + let sessionFactors; + let user: User | undefined; + let human: HumanUser | undefined; + let id: string | undefined; + + const doSend = send === "true"; + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + async function sendEmail(userId: string) { + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + if (invite === "true") { + await sendInviteEmailCode({ + userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + + (requestId ? `&requestId=${requestId}` : ""), + }).catch((error) => { + console.error("Could not send invitation email", error); + throw Error("Failed to send invitation email"); + }); + } else { + await sendEmailCode({ + userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + (requestId ? `&requestId=${requestId}` : ""), + }).catch((error) => { + console.error("Could not send verification email", error); + throw Error("Failed to send verification email"); + }); + } + } + + if ("loginName" in searchParams) { + sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + if (doSend && sessionFactors?.factors?.user?.id) { + await sendEmail(sessionFactors.factors.user.id); + } + } else if ("userId" in searchParams && userId) { + if (doSend) { + await sendEmail(userId); + } + + const userResponse = await getUserByID({ + serviceUrl, + userId, + }); + if (userResponse) { + user = userResponse.user; + if (user?.type.case === "human") { + human = user.type.value as HumanUser; + } + } + } + + id = userId ?? sessionFactors?.factors?.user?.id; + + if (!id) { + throw Error("Failed to get user id"); + } + + const params = new URLSearchParams({ + userId: userId, + initial: "true", // defines that a code is not required and is therefore not shown in the UI + }); + + if (loginName) { + params.set("loginName", loginName); + } + + if (organization) { + params.set("organization", organization); + } + + if (requestId) { + params.set("requestId", requestId); + } + + return ( + +
+

+ +

+

+ +

+ + {!id && ( +
+ + + +
+ )} + + {id && send && ( +
+ + + +
+ )} + + {sessionFactors ? ( + + ) : ( + user && ( + + ) + )} + + +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/verify/success/page.tsx b/login/apps/login/src/app/(login)/verify/success/page.tsx new file mode 100644 index 0000000000..a0df0327c4 --- /dev/null +++ b/login/apps/login/src/app/(login)/verify/success/page.tsx @@ -0,0 +1,92 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getUserByID, +} from "@/lib/zitadel"; +import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { loginName, requestId, organization, userId } = searchParams; + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }).catch((error) => { + console.warn("Error loading session:", error); + }); + + let loginSettings; + if (!requestId) { + loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + } + + const id = userId ?? sessionFactors?.factors?.user?.id; + + if (!id) { + throw Error("Failed to get user id"); + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: id, + }); + + let user: User | undefined; + let human: HumanUser | undefined; + + if (userResponse) { + user = userResponse.user; + if (user?.type.case === "human") { + human = user.type.value as HumanUser; + } + } + + return ( + +
+

+ +

+

+ +

+ + {sessionFactors ? ( + + ) : ( + user && ( + + ) + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/global-error.tsx b/login/apps/login/src/app/global-error.tsx new file mode 100644 index 0000000000..5111a65e8d --- /dev/null +++ b/login/apps/login/src/app/global-error.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Boundary } from "@/components/boundary"; +import { Button } from "@/components/button"; +import { ThemeWrapper } from "@/components/theme-wrapper"; +import { Translated } from "@/components/translated"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + // global-error must include html and body tags + + + + +
+
+ Error: {error?.message} +
+
+ +
+
+
+
+ + + ); +} diff --git a/login/apps/login/src/app/healthy/route.ts b/login/apps/login/src/app/healthy/route.ts new file mode 100644 index 0000000000..da41c2cca8 --- /dev/null +++ b/login/apps/login/src/app/healthy/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({}, { status: 200 }); +} diff --git a/login/apps/login/src/app/login/route.ts b/login/apps/login/src/app/login/route.ts new file mode 100644 index 0000000000..7b57e1a5e9 --- /dev/null +++ b/login/apps/login/src/app/login/route.ts @@ -0,0 +1,565 @@ +import { getAllSessions } from "@/lib/cookies"; +import { idpTypeToSlug } from "@/lib/idp"; +import { loginWithOIDCAndSession } from "@/lib/oidc"; +import { loginWithSAMLAndSession } from "@/lib/saml"; +import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; +import { constructUrl, getServiceUrlFromHeaders } from "@/lib/service-url"; +import { findValidSession } from "@/lib/session"; +import { + createCallback, + createResponse, + getActiveIdentityProviders, + getAuthRequest, + getOrgsByDomain, + getSAMLRequest, + getSecuritySettings, + listSessions, + startIdentityProviderFlow, +} from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { Prompt } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; +import { + CreateCallbackRequestSchema, + SessionSchema, +} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { DEFAULT_CSP } from "../../../constants/csp"; + +export const dynamic = "force-dynamic"; +export const revalidate = false; +export const fetchCache = "default-no-store"; + +const gotoAccounts = ({ + request, + requestId, + organization, +}: { + request: NextRequest; + requestId: string; + organization?: string; +}): NextResponse => { + const accountsUrl = constructUrl(request, "/accounts"); + + if (requestId) { + accountsUrl.searchParams.set("requestId", requestId); + } + if (organization) { + accountsUrl.searchParams.set("organization", organization); + } + + return NextResponse.redirect(accountsUrl); +}; + +async function loadSessions({ + serviceUrl, + ids, +}: { + serviceUrl: string; + ids: string[]; +}): Promise { + const response = await listSessions({ + serviceUrl, + ids: ids.filter((id: string | undefined) => !!id), + }); + + return response?.sessions ?? []; +} + +const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/; +const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/; // TODO: check regex for all domain character options +const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/; + +export async function GET(request: NextRequest) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const searchParams = request.nextUrl.searchParams; + + const oidcRequestId = searchParams.get("authRequest"); // oidc initiated request + const samlRequestId = searchParams.get("samlRequest"); // saml initiated request + + // internal request id which combines authRequest and samlRequest with the prefix oidc_ or saml_ + let requestId = + searchParams.get("requestId") ?? + (oidcRequestId + ? `oidc_${oidcRequestId}` + : samlRequestId + ? `saml_${samlRequestId}` + : undefined); + + const sessionId = searchParams.get("sessionId"); + + // TODO: find a better way to handle _rsc (react server components) requests and block them to avoid conflicts when creating oidc callback + const _rsc = searchParams.get("_rsc"); + if (_rsc) { + return NextResponse.json({ error: "No _rsc supported" }, { status: 500 }); + } + + const sessionCookies = await getAllSessions(); + const ids = sessionCookies.map((s) => s.id); + let sessions: Session[] = []; + if (ids && ids.length) { + sessions = await loadSessions({ serviceUrl, ids }); + } + + // complete flow if session and request id are provided + if (requestId && sessionId) { + if (requestId.startsWith("oidc_")) { + // this finishes the login process for OIDC + return loginWithOIDCAndSession({ + serviceUrl, + authRequest: requestId.replace("oidc_", ""), + sessionId, + sessions, + sessionCookies, + request, + }); + } else if (requestId.startsWith("saml_")) { + // this finishes the login process for SAML + return loginWithSAMLAndSession({ + serviceUrl, + samlRequest: requestId.replace("saml_", ""), + sessionId, + sessions, + sessionCookies, + request, + }); + } + } + + // continue with OIDC + if (requestId && requestId.startsWith("oidc_")) { + const { authRequest } = await getAuthRequest({ + serviceUrl, + authRequestId: requestId.replace("oidc_", ""), + }); + + let organization = ""; + let suffix = ""; + let idpId = ""; + + if (authRequest?.scope) { + const orgScope = authRequest.scope.find((s: string) => + ORG_SCOPE_REGEX.test(s), + ); + + const idpScope = authRequest.scope.find((s: string) => + IDP_SCOPE_REGEX.test(s), + ); + + if (orgScope) { + const matched = ORG_SCOPE_REGEX.exec(orgScope); + organization = matched?.[1] ?? ""; + } else { + const orgDomainScope = authRequest.scope.find((s: string) => + ORG_DOMAIN_SCOPE_REGEX.test(s), + ); + + if (orgDomainScope) { + const matched = ORG_DOMAIN_SCOPE_REGEX.exec(orgDomainScope); + const orgDomain = matched?.[1] ?? ""; + if (orgDomain) { + const orgs = await getOrgsByDomain({ + serviceUrl, + domain: orgDomain, + }); + if (orgs.result && orgs.result.length === 1) { + organization = orgs.result[0].id ?? ""; + suffix = orgDomain; + } + } + } + } + + if (idpScope) { + const matched = IDP_SCOPE_REGEX.exec(idpScope); + idpId = matched?.[1] ?? ""; + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: organization ? organization : undefined, + }).then((resp) => { + return resp.identityProviders; + }); + + const idp = identityProviders.find((idp) => idp.id === idpId); + + if (idp) { + const origin = request.nextUrl.origin; + + const identityProviderType = identityProviders[0].type; + + if (identityProviderType === IdentityProviderType.LDAP) { + const ldapUrl = constructUrl(request, "/ldap"); + if (authRequest.id) { + ldapUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); + } + if (organization) { + ldapUrl.searchParams.set("organization", organization); + } + + return NextResponse.redirect(ldapUrl); + } + + let provider = idpTypeToSlug(identityProviderType); + + const params = new URLSearchParams(); + + if (requestId) { + params.set("requestId", requestId); + } + + if (organization) { + params.set("organization", organization); + } + + let url: string | null = await startIdentityProviderFlow({ + serviceUrl, + idpId, + urls: { + successUrl: + `${origin}/idp/${provider}/success?` + + new URLSearchParams(params), + failureUrl: + `${origin}/idp/${provider}/failure?` + + new URLSearchParams(params), + }, + }); + + if (!url) { + return NextResponse.json( + { error: "Could not start IDP flow" }, + { status: 500 }, + ); + } + + if (url.startsWith("/")) { + // if the url is a relative path, construct the absolute url + url = constructUrl(request, url).toString(); + } + + return NextResponse.redirect(url); + } + } + } + + if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) { + const registerUrl = constructUrl(request, "/register"); + if (authRequest.id) { + registerUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); + } + if (organization) { + registerUrl.searchParams.set("organization", organization); + } + + return NextResponse.redirect(registerUrl); + } + + // use existing session and hydrate it for oidc + if (authRequest && sessions.length) { + // if some accounts are available for selection and select_account is set + if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) { + return gotoAccounts({ + request, + requestId: `oidc_${authRequest.id}`, + organization, + }); + } else if (authRequest.prompt.includes(Prompt.LOGIN)) { + /** + * The login prompt instructs the authentication server to prompt the user for re-authentication, regardless of whether the user is already authenticated + */ + + // if a hint is provided, skip loginname page and jump to the next page + if (authRequest.loginHint) { + try { + let command: SendLoginnameCommand = { + loginName: authRequest.loginHint, + requestId: authRequest.id, + }; + + if (organization) { + command = { ...command, organization }; + } + + const res = await sendLoginname(command); + + if (res && "redirect" in res && res?.redirect) { + const absoluteUrl = constructUrl(request, res.redirect); + return NextResponse.redirect(absoluteUrl.toString()); + } + } catch (error) { + console.error("Failed to execute sendLoginname:", error); + } + } + + const loginNameUrl = constructUrl(request, "/loginname"); + if (authRequest.id) { + loginNameUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); + } + if (authRequest.loginHint) { + loginNameUrl.searchParams.set("loginName", authRequest.loginHint); + } + if (organization) { + loginNameUrl.searchParams.set("organization", organization); + } + if (suffix) { + loginNameUrl.searchParams.set("suffix", suffix); + } + return NextResponse.redirect(loginNameUrl); + } else if (authRequest.prompt.includes(Prompt.NONE)) { + /** + * With an OIDC none prompt, the authentication server must not display any authentication or consent user interface pages. + * This means that the user should not be prompted to enter their password again. + * Instead, the server attempts to silently authenticate the user using an existing session or other authentication mechanisms that do not require user interaction + **/ + const securitySettings = await getSecuritySettings({ + serviceUrl, + }); + + const selectedSession = await findValidSession({ + serviceUrl, + sessions, + authRequest, + }); + + const noSessionResponse = NextResponse.json( + { error: "No active session found" }, + { status: 400 }, + ); + + if (securitySettings?.embeddedIframe?.enabled) { + securitySettings.embeddedIframe.allowedOrigins; + noSessionResponse.headers.set( + "Content-Security-Policy", + `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, + ); + noSessionResponse.headers.delete("X-Frame-Options"); + } + + if (!selectedSession || !selectedSession.id) { + return noSessionResponse; + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession.id, + ); + + if (!cookie || !cookie.id || !cookie.token) { + return noSessionResponse; + } + + const session = { + sessionId: cookie.id, + sessionToken: cookie.token, + }; + + const { callbackUrl } = await createCallback({ + serviceUrl, + req: create(CreateCallbackRequestSchema, { + authRequestId: requestId.replace("oidc_", ""), + callbackKind: { + case: "session", + value: create(SessionSchema, session), + }, + }), + }); + + const callbackResponse = NextResponse.redirect(callbackUrl); + + if (securitySettings?.embeddedIframe?.enabled) { + securitySettings.embeddedIframe.allowedOrigins; + callbackResponse.headers.set( + "Content-Security-Policy", + `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, + ); + callbackResponse.headers.delete("X-Frame-Options"); + } + + return callbackResponse; + } else { + // check for loginHint, userId hint and valid sessions + let selectedSession = await findValidSession({ + serviceUrl, + sessions, + authRequest, + }); + + if (!selectedSession || !selectedSession.id) { + return gotoAccounts({ + request, + requestId: `oidc_${authRequest.id}`, + organization, + }); + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession.id, + ); + + if (!cookie || !cookie.id || !cookie.token) { + return gotoAccounts({ + request, + requestId: `oidc_${authRequest.id}`, + organization, + }); + } + + const session = { + sessionId: cookie.id, + sessionToken: cookie.token, + }; + + try { + const { callbackUrl } = await createCallback({ + serviceUrl, + req: create(CreateCallbackRequestSchema, { + authRequestId: requestId.replace("oidc_", ""), + callbackKind: { + case: "session", + value: create(SessionSchema, session), + }, + }), + }); + if (callbackUrl) { + return NextResponse.redirect(callbackUrl); + } else { + console.log( + "could not create callback, redirect user to choose other account", + ); + return gotoAccounts({ + request, + organization, + requestId: `oidc_${authRequest.id}`, + }); + } + } catch (error) { + console.error(error); + return gotoAccounts({ + request, + requestId: `oidc_${authRequest.id}`, + organization, + }); + } + } + } else { + const loginNameUrl = constructUrl(request, "/loginname"); + + loginNameUrl.searchParams.set("requestId", requestId); + if (authRequest?.loginHint) { + loginNameUrl.searchParams.set("loginName", authRequest.loginHint); + loginNameUrl.searchParams.set("submit", "true"); // autosubmit + } + + if (organization) { + loginNameUrl.searchParams.append("organization", organization); + // loginNameUrl.searchParams.set("organization", organization); + } + + return NextResponse.redirect(loginNameUrl); + } + } + // continue with SAML + else if (requestId && requestId.startsWith("saml_")) { + const { samlRequest } = await getSAMLRequest({ + serviceUrl, + samlRequestId: requestId.replace("saml_", ""), + }); + + if (!samlRequest) { + return NextResponse.json( + { error: "No samlRequest found" }, + { status: 400 }, + ); + } + + let selectedSession = await findValidSession({ + serviceUrl, + sessions, + samlRequest, + }); + + if (!selectedSession || !selectedSession.id) { + return gotoAccounts({ + request, + requestId: `saml_${samlRequest.id}`, + }); + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession.id, + ); + + if (!cookie || !cookie.id || !cookie.token) { + return gotoAccounts({ + request, + requestId: `saml_${samlRequest.id}`, + // organization, + }); + } + + const session = { + sessionId: cookie.id, + sessionToken: cookie.token, + }; + + try { + const { url, binding } = await createResponse({ + serviceUrl, + req: create(CreateResponseRequestSchema, { + samlRequestId: requestId.replace("saml_", ""), + responseKind: { + case: "session", + value: session, + }, + }), + }); + if (url && binding.case === "redirect") { + return NextResponse.redirect(url); + } else if (url && binding.case === "post") { + // Create HTML form that auto-submits via POST and escape the SAML cookie + const html = ` + + +
+ + + +
+ + + `; + + return new NextResponse(html, { + headers: { "Content-Type": "text/html" }, + }); + } else { + console.log( + "could not create response, redirect user to choose other account", + ); + return gotoAccounts({ + request, + requestId: `saml_${samlRequest.id}`, + }); + } + } catch (error) { + console.error(error); + return gotoAccounts({ + request, + requestId: `saml_${samlRequest.id}`, + }); + } + } + // Device Authorization does not need to start here as it is handled on the /device endpoint + else { + return NextResponse.json( + { error: "No authRequest nor samlRequest provided" }, + { status: 500 }, + ); + } +} diff --git a/login/apps/login/src/app/security/route.ts b/login/apps/login/src/app/security/route.ts new file mode 100644 index 0000000000..4a2b6d4854 --- /dev/null +++ b/login/apps/login/src/app/security/route.ts @@ -0,0 +1,28 @@ +import { createServiceForHost } from "@/lib/service"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { Client } from "@zitadel/client"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const settings = await settingsService + .getSecuritySettings({}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + const response = NextResponse.json({ settings }, { status: 200 }); + + // Add Cache-Control header to cache the response for up to 1 hour + response.headers.set( + "Cache-Control", + "public, max-age=3600, stale-while-revalidate=86400", + ); + + return response; +} diff --git a/login/apps/login/src/components/address-bar.tsx b/login/apps/login/src/components/address-bar.tsx new file mode 100644 index 0000000000..7e7bda6bd0 --- /dev/null +++ b/login/apps/login/src/components/address-bar.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { Fragment } from "react"; + +type Props = { + domain: string; +}; + +export function AddressBar({ domain }: Props) { + const pathname = usePathname(); + + return ( +
+
+ + + +
+
+
+ {domain} +
+ {pathname ? ( + <> + / + {pathname + .split("/") + .slice(1) + .filter((s) => !!s) + .map((segment) => { + return ( + + + + {segment} + + + + / + + ); + })} + + ) : null} +
+
+ ); +} diff --git a/login/apps/login/src/components/alert.tsx b/login/apps/login/src/components/alert.tsx new file mode 100644 index 0000000000..417e67934e --- /dev/null +++ b/login/apps/login/src/components/alert.tsx @@ -0,0 +1,45 @@ +import { + ExclamationTriangleIcon, + InformationCircleIcon, +} from "@heroicons/react/24/outline"; +import { clsx } from "clsx"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; + type?: AlertType; +}; + +export enum AlertType { + ALERT, + INFO, +} + +const yellow = + "border-yellow-600/40 dark:border-yellow-500/20 bg-yellow-200/30 text-yellow-600 dark:bg-yellow-700/20 dark:text-yellow-200"; +const red = + "border-red-600/40 dark:border-red-500/20 bg-red-200/30 text-red-600 dark:bg-red-700/20 dark:text-red-200"; +const neutral = + "border-divider-light dark:border-divider-dark bg-black/5 text-gray-600 dark:bg-white/10 dark:text-gray-200"; + +export function Alert({ children, type = AlertType.ALERT }: Props) { + return ( +
+ {type === AlertType.ALERT && ( + + )} + {type === AlertType.INFO && ( + + )} + {children} +
+ ); +} diff --git a/login/apps/login/src/components/app-avatar.tsx b/login/apps/login/src/components/app-avatar.tsx new file mode 100644 index 0000000000..defe388438 --- /dev/null +++ b/login/apps/login/src/components/app-avatar.tsx @@ -0,0 +1,48 @@ +import { ColorShade, getColorHash } from "@/helpers/colors"; +import { useTheme } from "next-themes"; +import Image from "next/image"; +import { getInitials } from "./avatar"; + +interface AvatarProps { + appName: string; + imageUrl?: string; + shadow?: boolean; +} + +export function AppAvatar({ appName, imageUrl, shadow }: AvatarProps) { + const { resolvedTheme } = useTheme(); + const credentials = getInitials(appName, appName); + + const color: ColorShade = getColorHash(appName); + + const avatarStyleDark = { + backgroundColor: color[900], + color: color[200], + }; + + const avatarStyleLight = { + backgroundColor: color[200], + color: color[900], + }; + + return ( +
+ {imageUrl ? ( + avatar + ) : ( + {credentials} + )} +
+ ); +} diff --git a/login/apps/login/src/components/auth-methods.tsx b/login/apps/login/src/components/auth-methods.tsx new file mode 100644 index 0000000000..ff0bcf0b32 --- /dev/null +++ b/login/apps/login/src/components/auth-methods.tsx @@ -0,0 +1,234 @@ +import { CheckIcon } from "@heroicons/react/24/solid"; +import { clsx } from "clsx"; +import Link from "next/link"; +import { ReactNode } from "react"; +import { BadgeState, StateBadge } from "./state-badge"; + +const cardClasses = (alreadyAdded: boolean) => + clsx( + "relative bg-background-light-400 dark:bg-background-dark-400 group block space-y-1.5 rounded-md px-5 py-3 border border-divider-light dark:border-divider-dark transition-all ", + alreadyAdded + ? "opacity-50 cursor-default" + : "hover:shadow-lg hover:dark:bg-white/10", + ); + +const LinkWrapper = ({ + alreadyAdded, + children, + link, +}: { + alreadyAdded: boolean; + children: ReactNode; + link: string; +}) => { + return !alreadyAdded ? ( + + {children} + + ) : ( +
{children}
+ ); +}; + +export const TOTP = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + timer-lock-outline + + {" "} + Authenticator App +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +export const U2F = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + + + Universal Second Factor +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +export const EMAIL = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + + + + Code via Email +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +export const SMS = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + + + Code via SMS +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +export const PASSKEYS = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + + + Passkeys +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +export const PASSWORD = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + form-textbox-password + + + Password +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +function Setup() { + return ( +
+ + + +
+ ); +} diff --git a/login/apps/login/src/components/authentication-method-radio.tsx b/login/apps/login/src/components/authentication-method-radio.tsx new file mode 100644 index 0000000000..c3b273ab46 --- /dev/null +++ b/login/apps/login/src/components/authentication-method-radio.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { RadioGroup } from "@headlessui/react"; +import { Translated } from "./translated"; + +export enum AuthenticationMethod { + Passkey = "passkey", + Password = "password", +} + +export const methods = [ + AuthenticationMethod.Passkey, + AuthenticationMethod.Password, +]; + +export function AuthenticationMethodRadio({ + selected, + selectionChanged, +}: { + selected: any; + selectionChanged: (value: any) => void; +}) { + return ( +
+
+ + Server size +
+ {methods.map((method) => ( + + `${ + active + ? "ring-2 ring-opacity-60 ring-primary-light-500 dark:ring-white/20" + : "" + } + ${ + checked + ? "bg-background-light-400 dark:bg-background-dark-400 ring-2 ring-primary-light-500 dark:ring-primary-dark-500" + : "bg-background-light-400 dark:bg-background-dark-400" + } + h-full flex-1 relative border boder-divider-light dark:border-divider-dark flex cursor-pointer rounded-lg px-5 py-4 focus:outline-none hover:shadow-lg dark:hover:bg-white/10` + } + > + {({ active, checked }) => ( + <> +
+ {method === "passkey" && ( + + + + )} + {method === "password" && ( + + form-textbox-password + + + )} + + {method === AuthenticationMethod.Passkey && ( + + )} + {method === AuthenticationMethod.Password && ( + + )} + +
+ + )} +
+ ))} +
+
+
+
+ ); +} diff --git a/login/apps/login/src/components/avatar.tsx b/login/apps/login/src/components/avatar.tsx new file mode 100644 index 0000000000..2300659875 --- /dev/null +++ b/login/apps/login/src/components/avatar.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { ColorShade, getColorHash } from "@/helpers/colors"; +import { useTheme } from "next-themes"; +import Image from "next/image"; + +interface AvatarProps { + name: string | null | undefined; + loginName: string; + imageUrl?: string; + size?: "small" | "base" | "large"; + shadow?: boolean; +} + +export function getInitials(name: string, loginName: string) { + let credentials = ""; + if (name) { + const split = name.split(" "); + if (split) { + const initials = + split[0].charAt(0) + (split[1] ? split[1].charAt(0) : ""); + credentials = initials; + } else { + credentials = name.charAt(0); + } + } else { + const username = loginName.split("@")[0]; + let separator = "_"; + if (username.includes("-")) { + separator = "-"; + } + if (username.includes(".")) { + separator = "."; + } + const split = username.split(separator); + const initials = split[0].charAt(0) + (split[1] ? split[1].charAt(0) : ""); + credentials = initials; + } + + return credentials; +} + +export function Avatar({ + size = "base", + name, + loginName, + imageUrl, + shadow, +}: AvatarProps) { + const { resolvedTheme } = useTheme(); + const credentials = getInitials(name ?? loginName, loginName); + + const color: ColorShade = getColorHash(loginName); + + const avatarStyleDark = { + backgroundColor: color[900], + color: color[200], + }; + + const avatarStyleLight = { + backgroundColor: color[200], + color: color[900], + }; + + return ( +
+ {imageUrl ? ( + avatar + ) : ( + + {credentials} + + )} +
+ ); +} diff --git a/login/apps/login/src/components/back-button.tsx b/login/apps/login/src/components/back-button.tsx new file mode 100644 index 0000000000..31d4a880ad --- /dev/null +++ b/login/apps/login/src/components/back-button.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Button, ButtonVariants } from "./button"; +import { Translated } from "./translated"; + +export function BackButton() { + const router = useRouter(); + return ( + + ); +} diff --git a/login/apps/login/src/components/boundary.tsx b/login/apps/login/src/components/boundary.tsx new file mode 100644 index 0000000000..354d920960 --- /dev/null +++ b/login/apps/login/src/components/boundary.tsx @@ -0,0 +1,83 @@ +import { clsx } from "clsx"; +import { ReactNode } from "react"; + +const Label = ({ + children, + animateRerendering, + color, +}: { + children: ReactNode; + animateRerendering?: boolean; + color?: "default" | "pink" | "blue" | "violet" | "cyan" | "orange" | "red"; +}) => { + return ( +
+ {children} +
+ ); +}; +export const Boundary = ({ + children, + labels = ["children"], + size = "default", + color = "default", + animateRerendering = true, +}: { + children: ReactNode; + labels?: string[]; + size?: "small" | "default"; + color?: "default" | "pink" | "blue" | "violet" | "cyan" | "orange" | "red"; + animateRerendering?: boolean; +}) => { + return ( +
+
+ {labels.map((label) => { + return ( + + ); + })} +
+ + {children} +
+ ); +}; diff --git a/login/apps/login/src/components/button.tsx b/login/apps/login/src/components/button.tsx new file mode 100644 index 0000000000..a25a30538a --- /dev/null +++ b/login/apps/login/src/components/button.tsx @@ -0,0 +1,74 @@ +import { clsx } from "clsx"; +import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from "react"; + +export enum ButtonSizes { + Small = "Small", + Large = "Large", +} + +export enum ButtonVariants { + Primary = "Primary", + Secondary = "Secondary", + Destructive = "Destructive", +} + +export enum ButtonColors { + Neutral = "Neutral", + Primary = "Primary", + Warn = "Warn", +} + +export type ButtonProps = DetailedHTMLProps< + ButtonHTMLAttributes, + HTMLButtonElement +> & { + size?: ButtonSizes; + variant?: ButtonVariants; + color?: ButtonColors; +}; + +export const getButtonClasses = ( + size: ButtonSizes, + variant: ButtonVariants, + color: ButtonColors, +) => + clsx({ + "box-border font-normal leading-36px text-14px inline-flex items-center rounded-md focus:outline-none transition-colors transition-shadow duration-300": + true, + "shadow hover:shadow-xl active:shadow-xl disabled:border-none disabled:bg-gray-300 disabled:text-gray-600 disabled:shadow-none disabled:cursor-not-allowed disabled:dark:bg-gray-800 disabled:dark:text-gray-900": + variant === ButtonVariants.Primary, + "bg-primary-light-500 dark:bg-primary-dark-500 hover:bg-primary-light-400 hover:dark:bg-primary-dark-400 text-primary-light-contrast-500 dark:text-primary-dark-contrast-500": + variant === ButtonVariants.Primary && color !== ButtonColors.Warn, + "bg-warn-light-500 dark:bg-warn-dark-500 hover:bg-warn-light-400 hover:dark:bg-warn-dark-400 text-white dark:text-white": + variant === ButtonVariants.Primary && color === ButtonColors.Warn, + "border border-button-light-border dark:border-button-dark-border text-gray-950 hover:bg-gray-500 hover:bg-opacity-20 hover:dark:bg-white hover:dark:bg-opacity-10 focus:bg-gray-500 focus:bg-opacity-20 focus:dark:bg-white focus:dark:bg-opacity-10 dark:text-white disabled:text-gray-600 disabled:hover:bg-transparent disabled:dark:hover:bg-transparent disabled:cursor-not-allowed disabled:dark:text-gray-900": + variant === ButtonVariants.Secondary, + "border border-button-light-border dark:border-button-dark-border text-warn-light-500 dark:text-warn-dark-500 hover:bg-warn-light-500 hover:bg-opacity-10 dark:hover:bg-warn-light-500 dark:hover:bg-opacity-10 focus:bg-warn-light-500 focus:bg-opacity-20 dark:focus:bg-warn-light-500 dark:focus:bg-opacity-20": + color === ButtonColors.Warn && variant !== ButtonVariants.Primary, + "px-16 py-2": size === ButtonSizes.Large, + "px-4 h-[36px]": size === ButtonSizes.Small, + }); + +// eslint-disable-next-line react/display-name +export const Button = forwardRef( + ( + { + children, + className = "", + variant = ButtonVariants.Primary, + size = ButtonSizes.Small, + color = ButtonColors.Primary, + ...props + }, + ref, + ) => ( + + ), +); diff --git a/login/apps/login/src/components/change-password-form.tsx b/login/apps/login/src/components/change-password-form.tsx new file mode 100644 index 0000000000..00513d8dda --- /dev/null +++ b/login/apps/login/src/components/change-password-form.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { + lowerCaseValidator, + numberValidator, + symbolValidator, + upperCaseValidator, +} from "@/helpers/validators"; +import { + checkSessionAndSetPassword, + sendPassword, +} from "@/lib/server/password"; +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { PasswordComplexity } from "./password-complexity"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = + | { + password: string; + confirmPassword: string; + } + | FieldValues; + +type Props = { + passwordComplexitySettings: PasswordComplexitySettings; + sessionId: string; + loginName: string; + requestId?: string; + organization?: string; +}; + +export function ChangePasswordForm({ + passwordComplexitySettings, + sessionId, + loginName, + requestId, + organization, +}: Props) { + const router = useRouter(); + + const { register, handleSubmit, watch, formState } = useForm({ + mode: "onBlur", + defaultValues: { + password: "", + comfirmPassword: "", + }, + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function submitChange(values: Inputs) { + setLoading(true); + + const changeResponse = checkSessionAndSetPassword({ + sessionId, + password: values.password, + }) + .catch(() => { + setError("Could not change password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (changeResponse && "error" in changeResponse && changeResponse.error) { + setError( + typeof changeResponse.error === "string" + ? changeResponse.error + : "Unknown error", + ); + return; + } + + if (!changeResponse) { + setError("Could not change password"); + return; + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for a second, to prevent eventual consistency issues + + const passwordResponse = await sendPassword({ + loginName, + organization, + checks: create(ChecksSchema, { + password: { password: values.password }, + }), + requestId, + }) + .catch(() => { + setError("Could not verify password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if ( + passwordResponse && + "error" in passwordResponse && + passwordResponse.error + ) { + setError(passwordResponse.error); + return; + } + + if ( + passwordResponse && + "redirect" in passwordResponse && + passwordResponse.redirect + ) { + return router.push(passwordResponse.redirect); + } + + return; + } + + const { errors } = formState; + + const watchPassword = watch("password", ""); + const watchConfirmPassword = watch("confirmPassword", ""); + + const hasMinLength = + passwordComplexitySettings && + watchPassword?.length >= passwordComplexitySettings.minLength; + const hasSymbol = symbolValidator(watchPassword); + const hasNumber = numberValidator(watchPassword); + const hasUppercase = upperCaseValidator(watchPassword); + const hasLowercase = lowerCaseValidator(watchPassword); + + const policyIsValid = + passwordComplexitySettings && + (passwordComplexitySettings.requiresLowercase ? hasLowercase : true) && + (passwordComplexitySettings.requiresNumber ? hasNumber : true) && + (passwordComplexitySettings.requiresUppercase ? hasUppercase : true) && + (passwordComplexitySettings.requiresSymbol ? hasSymbol : true) && + hasMinLength; + + return ( +
+
+
+ +
+
+ +
+
+ + {passwordComplexitySettings && ( + + )} + + {error && {error}} + +
+ + +
+ + ); +} diff --git a/login/apps/login/src/components/checkbox.tsx b/login/apps/login/src/components/checkbox.tsx new file mode 100644 index 0000000000..41b45aad92 --- /dev/null +++ b/login/apps/login/src/components/checkbox.tsx @@ -0,0 +1,62 @@ +import classNames from "clsx"; +import { + DetailedHTMLProps, + forwardRef, + InputHTMLAttributes, + useEffect, + useState, +} from "react"; + +export type CheckboxProps = DetailedHTMLProps< + InputHTMLAttributes, + HTMLInputElement +> & { + checked: boolean; + disabled?: boolean; + onChangeVal?: (checked: boolean) => void; +}; + +export const Checkbox = forwardRef( + function Checkbox( + { + className = "", + checked = false, + disabled = false, + onChangeVal, + children, + ...props + }, + ref, + ) { + const [enabled, setEnabled] = useState(checked); + + useEffect(() => { + setEnabled(checked); + }, [checked]); + + return ( +
+
+
+ { + setEnabled(event.target?.checked); + onChangeVal && onChangeVal(event.target?.checked); + }} + disabled={disabled} + type="checkbox" + className={classNames( + "form-checkbox rounded border-gray-300 text-primary-light-500 dark:text-primary-dark-500 shadow-sm focus:border-indigo-300 focus:ring focus:ring-offset-0 focus:ring-indigo-200 focus:ring-opacity-50", + className, + )} + {...props} + /> +
+
+ {children} +
+ ); + }, +); diff --git a/login/apps/login/src/components/choose-authenticator-to-login.tsx b/login/apps/login/src/components/choose-authenticator-to-login.tsx new file mode 100644 index 0000000000..0f5dd79134 --- /dev/null +++ b/login/apps/login/src/components/choose-authenticator-to-login.tsx @@ -0,0 +1,38 @@ +import { + LoginSettings, + PasskeysType, +} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { PASSKEYS, PASSWORD } from "./auth-methods"; +import { Translated } from "./translated"; + +type Props = { + authMethods: AuthenticationMethodType[]; + params: URLSearchParams; + loginSettings: LoginSettings | undefined; +}; + +export function ChooseAuthenticatorToLogin({ + authMethods, + params, + loginSettings, +}: Props) { + return ( + <> + {authMethods.includes(AuthenticationMethodType.PASSWORD) && + loginSettings?.allowUsernamePassword && ( +
+ +
+ )} +
+ {authMethods.includes(AuthenticationMethodType.PASSWORD) && + loginSettings?.allowUsernamePassword && + PASSWORD(false, "/password?" + params)} + {authMethods.includes(AuthenticationMethodType.PASSKEY) && + loginSettings?.passkeysType == PasskeysType.ALLOWED && + PASSKEYS(false, "/passkey?" + params)} +
+ + ); +} diff --git a/login/apps/login/src/components/choose-authenticator-to-setup.tsx b/login/apps/login/src/components/choose-authenticator-to-setup.tsx new file mode 100644 index 0000000000..4aa4de720a --- /dev/null +++ b/login/apps/login/src/components/choose-authenticator-to-setup.tsx @@ -0,0 +1,51 @@ +import { + LoginSettings, + PasskeysType, +} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { Alert, AlertType } from "./alert"; +import { PASSKEYS, PASSWORD } from "./auth-methods"; +import { Translated } from "./translated"; + +type Props = { + authMethods: AuthenticationMethodType[]; + params: URLSearchParams; + loginSettings: LoginSettings; +}; + +export function ChooseAuthenticatorToSetup({ + authMethods, + params, + loginSettings, +}: Props) { + if (authMethods.length !== 0) { + return ( + + + + ); + } else { + return ( + <> + {loginSettings.passkeysType == PasskeysType.NOT_ALLOWED && + !loginSettings.allowUsernamePassword && ( + + + + )} + +
+ {!authMethods.includes(AuthenticationMethodType.PASSWORD) && + loginSettings.allowUsernamePassword && + PASSWORD(false, "/password/set?" + params)} + {!authMethods.includes(AuthenticationMethodType.PASSKEY) && + loginSettings.passkeysType == PasskeysType.ALLOWED && + PASSKEYS(false, "/passkey/set?" + params)} +
+ + ); + } +} diff --git a/login/apps/login/src/components/choose-second-factor-to-setup.tsx b/login/apps/login/src/components/choose-second-factor-to-setup.tsx new file mode 100644 index 0000000000..edd0ae2b61 --- /dev/null +++ b/login/apps/login/src/components/choose-second-factor-to-setup.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { skipMFAAndContinueWithNextUrl } from "@/lib/server/session"; +import { + LoginSettings, + SecondFactorType, +} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { useRouter } from "next/navigation"; +import { EMAIL, SMS, TOTP, U2F } from "./auth-methods"; +import { Translated } from "./translated"; + +type Props = { + userId: string; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; + loginSettings: LoginSettings; + userMethods: AuthenticationMethodType[]; + checkAfter: boolean; + phoneVerified: boolean; + emailVerified: boolean; + force: boolean; +}; + +export function ChooseSecondFactorToSetup({ + userId, + loginName, + sessionId, + requestId, + organization, + loginSettings, + userMethods, + checkAfter, + phoneVerified, + emailVerified, + force, +}: Props) { + const router = useRouter(); + const params = new URLSearchParams({}); + + if (loginName) { + params.append("loginName", loginName); + } + if (sessionId) { + params.append("sessionId", sessionId); + } + if (requestId) { + params.append("requestId", requestId); + } + if (organization) { + params.append("organization", organization); + } + if (checkAfter) { + params.append("checkAfter", "true"); + } + + return ( + <> +
+ {loginSettings.secondFactors.map((factor) => { + switch (factor) { + case SecondFactorType.OTP: + return TOTP( + userMethods.includes(AuthenticationMethodType.TOTP), + "/otp/time-based/set?" + params, + ); + case SecondFactorType.U2F: + return U2F( + userMethods.includes(AuthenticationMethodType.U2F), + "/u2f/set?" + params, + ); + case SecondFactorType.OTP_EMAIL: + return ( + emailVerified && + EMAIL( + userMethods.includes(AuthenticationMethodType.OTP_EMAIL), + "/otp/email/set?" + params, + ) + ); + case SecondFactorType.OTP_SMS: + return ( + phoneVerified && + SMS( + userMethods.includes(AuthenticationMethodType.OTP_SMS), + "/otp/sms/set?" + params, + ) + ); + default: + return null; + } + })} +
+ {!force && ( + + )} + + ); +} diff --git a/login/apps/login/src/components/choose-second-factor.tsx b/login/apps/login/src/components/choose-second-factor.tsx new file mode 100644 index 0000000000..6cd890f11d --- /dev/null +++ b/login/apps/login/src/components/choose-second-factor.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { EMAIL, SMS, TOTP, U2F } from "./auth-methods"; + +type Props = { + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; + userMethods: AuthenticationMethodType[]; +}; + +export function ChooseSecondFactor({ + loginName, + sessionId, + requestId, + organization, + userMethods, +}: Props) { + const params = new URLSearchParams({}); + + if (loginName) { + params.append("loginName", loginName); + } + if (sessionId) { + params.append("sessionId", sessionId); + } + if (requestId) { + params.append("requestId", requestId); + } + if (organization) { + params.append("organization", organization); + } + + return ( +
+ {userMethods.map((method, i) => { + return ( +
+ {method === AuthenticationMethodType.TOTP && + TOTP(false, "/otp/time-based?" + params)} + {method === AuthenticationMethodType.U2F && + U2F(false, "/u2f?" + params)} + {method === AuthenticationMethodType.OTP_EMAIL && + EMAIL(false, "/otp/email?" + params)} + {method === AuthenticationMethodType.OTP_SMS && + SMS(false, "/otp/sms?" + params)} +
+ ); + })} +
+ ); +} diff --git a/login/apps/login/src/components/consent.tsx b/login/apps/login/src/components/consent.tsx new file mode 100644 index 0000000000..e60ed2901b --- /dev/null +++ b/login/apps/login/src/components/consent.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { completeDeviceAuthorization } from "@/lib/server/device"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Alert } from "./alert"; +import { Button, ButtonVariants } from "./button"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +export function ConsentScreen({ + scope, + nextUrl, + deviceAuthorizationRequestId, + appName, +}: { + scope?: string[]; + nextUrl: string; + deviceAuthorizationRequestId: string; + appName?: string; +}) { + const t = useTranslations(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + + async function denyDeviceAuth() { + setLoading(true); + const response = await completeDeviceAuthorization( + deviceAuthorizationRequestId, + ) + .catch(() => { + setError("Could not register user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response) { + return router.push("/device"); + } + } + + const scopes = scope?.filter((s) => !!s); + + return ( +
+
    + {scopes?.length === 0 && ( + + + + )} + {scopes?.map((s) => { + const translationKey = `device.scope.${s}`; + const description = t(translationKey, null); + + // Check if the key itself is returned and provide a fallback + const resolvedDescription = + description === translationKey ? "" : description; + + return ( +
  • + {resolvedDescription} +
  • + ); + })} +
+ +

+ +

+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + +
+
+ ); +} diff --git a/login/apps/login/src/components/copy-to-clipboard.tsx b/login/apps/login/src/components/copy-to-clipboard.tsx new file mode 100644 index 0000000000..cf0dedc060 --- /dev/null +++ b/login/apps/login/src/components/copy-to-clipboard.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { + ClipboardDocumentCheckIcon, + ClipboardIcon, +} from "@heroicons/react/20/solid"; +import copy from "copy-to-clipboard"; +import { useEffect, useState } from "react"; + +type Props = { + value: string; +}; + +export function CopyToClipboard({ value }: Props) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (copied) { + copy(value); + const to = setTimeout(setCopied, 1000, false); + return () => clearTimeout(to); + } + }, [copied]); + + return ( +
+ +
+ ); +} diff --git a/login/apps/login/src/components/default-tags.tsx b/login/apps/login/src/components/default-tags.tsx new file mode 100644 index 0000000000..dc14f1bc1e --- /dev/null +++ b/login/apps/login/src/components/default-tags.tsx @@ -0,0 +1,32 @@ +// Default tags we want shared across the app +export function DefaultTags() { + return ( + <> + + + + + + {/* */} + + + ); +} diff --git a/login/apps/login/src/components/device-code-form.tsx b/login/apps/login/src/components/device-code-form.tsx new file mode 100644 index 0000000000..a1efc07207 --- /dev/null +++ b/login/apps/login/src/components/device-code-form.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Alert } from "@/components/alert"; +import { getDeviceAuthorizationRequest } from "@/lib/server/oidc"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = { + userCode: string; +}; + +export function DeviceCodeForm({ userCode }: { userCode?: string }) { + const router = useRouter(); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + userCode: userCode || "", + }, + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + async function submitCodeAndContinue(value: Inputs): Promise { + setLoading(true); + + const response = await getDeviceAuthorizationRequest(value.userCode) + .catch(() => { + setError("Could not continue the request"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (!response || !response.deviceAuthorizationRequest?.id) { + setError("Could not continue the request"); + return; + } + + return router.push( + `/device/consent?` + + new URLSearchParams({ + requestId: `device_${response.deviceAuthorizationRequest.id}`, + user_code: value.userCode, + }).toString(), + ); + } + + return ( + <> +
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ + ); +} diff --git a/login/apps/login/src/components/dynamic-theme.tsx b/login/apps/login/src/components/dynamic-theme.tsx new file mode 100644 index 0000000000..d50bc082ea --- /dev/null +++ b/login/apps/login/src/components/dynamic-theme.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Logo } from "@/components/logo"; +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { ReactNode } from "react"; +import { AppAvatar } from "./app-avatar"; +import { ThemeWrapper } from "./theme-wrapper"; + +export function DynamicTheme({ + branding, + children, + appName, +}: { + children: ReactNode; + branding?: BrandingSettings; + appName?: string; +}) { + return ( + +
+
+
+ {branding && ( + <> + + + {appName && } + + )} +
+ +
{children}
+
+
+
+
+ ); +} diff --git a/login/apps/login/src/components/external-link.tsx b/login/apps/login/src/components/external-link.tsx new file mode 100644 index 0000000000..a52164d35d --- /dev/null +++ b/login/apps/login/src/components/external-link.tsx @@ -0,0 +1,21 @@ +import { ArrowRightIcon } from "@heroicons/react/24/solid"; +import { ReactNode } from "react"; + +export const ExternalLink = ({ + children, + href, +}: { + children: ReactNode; + href: string; +}) => { + return ( + +
{children}
+ + +
+ ); +}; diff --git a/login/apps/login/src/components/idp-signin.tsx b/login/apps/login/src/components/idp-signin.tsx new file mode 100644 index 0000000000..a7c938e90c --- /dev/null +++ b/login/apps/login/src/components/idp-signin.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { createNewSessionFromIdpIntent } from "@/lib/server/idp"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Alert } from "./alert"; +import { Spinner } from "./spinner"; + +type Props = { + userId: string; + // organization: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + requestId?: string; +}; + +export function IdpSignin({ + userId, + idpIntent: { idpIntentId, idpIntentToken }, + requestId, +}: Props) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const router = useRouter(); + + useEffect(() => { + createNewSessionFromIdpIntent({ + userId, + idpIntent: { + idpIntentId, + idpIntentToken, + }, + requestId, + }) + .then((response) => { + if (response && "error" in response && response?.error) { + setError(response?.error); + return; + } + + if (response && "redirect" in response && response?.redirect) { + return router.push(response.redirect); + } + }) + .catch(() => { + setError("An internal error occurred"); + return; + }) + .finally(() => { + setLoading(false); + }); + }, []); + + return ( +
+ {loading && } + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/login/apps/login/src/components/idps/base-button.tsx b/login/apps/login/src/components/idps/base-button.tsx new file mode 100644 index 0000000000..0185c57996 --- /dev/null +++ b/login/apps/login/src/components/idps/base-button.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { clsx } from "clsx"; +import { Loader2Icon } from "lucide-react"; +import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from "react"; +import { useFormStatus } from "react-dom"; + +export type SignInWithIdentityProviderProps = DetailedHTMLProps< + ButtonHTMLAttributes, + HTMLButtonElement +> & { + name?: string; + e2e?: string; +}; + +export const BaseButton = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function BaseButton(props, ref) { + const formStatus = useFormStatus(); + + return ( + + ); +}); diff --git a/login/apps/login/src/components/idps/pages/complete-idp.tsx b/login/apps/login/src/components/idps/pages/complete-idp.tsx new file mode 100644 index 0000000000..2061a28e3e --- /dev/null +++ b/login/apps/login/src/components/idps/pages/complete-idp.tsx @@ -0,0 +1,55 @@ +import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete"; +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { AddHumanUserRequest } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { DynamicTheme } from "../../dynamic-theme"; +import { Translated } from "../../translated"; + +export async function completeIDP({ + idpUserId, + idpId, + idpUserName, + addHumanUser, + requestId, + organization, + branding, + idpIntent, +}: { + idpUserId: string; + idpId: string; + idpUserName: string; + addHumanUser?: AddHumanUserRequest; + requestId?: string; + organization: string; + branding?: BrandingSettings; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; +}) { + return ( + +
+

+ +

+

+ +

+ + +
+
+ ); +} diff --git a/login/apps/login/src/components/idps/pages/linking-failed.tsx b/login/apps/login/src/components/idps/pages/linking-failed.tsx new file mode 100644 index 0000000000..0c5a8264c4 --- /dev/null +++ b/login/apps/login/src/components/idps/pages/linking-failed.tsx @@ -0,0 +1,27 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { Alert, AlertType } from "../../alert"; +import { DynamicTheme } from "../../dynamic-theme"; +import { Translated } from "../../translated"; + +export async function linkingFailed( + branding?: BrandingSettings, + error?: string, +) { + return ( + +
+

+ +

+

+ +

+ {error && ( +
+ {{error}} +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/components/idps/pages/linking-success.tsx b/login/apps/login/src/components/idps/pages/linking-success.tsx new file mode 100644 index 0000000000..8d41cd8c32 --- /dev/null +++ b/login/apps/login/src/components/idps/pages/linking-success.tsx @@ -0,0 +1,30 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { DynamicTheme } from "../../dynamic-theme"; +import { IdpSignin } from "../../idp-signin"; +import { Translated } from "../../translated"; + +export async function linkingSuccess( + userId: string, + idpIntent: { idpIntentId: string; idpIntentToken: string }, + requestId?: string, + branding?: BrandingSettings, +) { + return ( + +
+

+ +

+

+ +

+ + +
+
+ ); +} diff --git a/login/apps/login/src/components/idps/pages/login-failed.tsx b/login/apps/login/src/components/idps/pages/login-failed.tsx new file mode 100644 index 0000000000..70c46919bf --- /dev/null +++ b/login/apps/login/src/components/idps/pages/login-failed.tsx @@ -0,0 +1,24 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { Alert, AlertType } from "../../alert"; +import { DynamicTheme } from "../../dynamic-theme"; +import { Translated } from "../../translated"; + +export async function loginFailed(branding?: BrandingSettings, error?: string) { + return ( + +
+

+ +

+

+ +

+ {error && ( +
+ {{error}} +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/components/idps/pages/login-success.tsx b/login/apps/login/src/components/idps/pages/login-success.tsx new file mode 100644 index 0000000000..6beec160a9 --- /dev/null +++ b/login/apps/login/src/components/idps/pages/login-success.tsx @@ -0,0 +1,30 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { DynamicTheme } from "../../dynamic-theme"; +import { IdpSignin } from "../../idp-signin"; +import { Translated } from "../../translated"; + +export async function loginSuccess( + userId: string, + idpIntent: { idpIntentId: string; idpIntentToken: string }, + requestId?: string, + branding?: BrandingSettings, +) { + return ( + +
+

+ +

+

+ +

+ + +
+
+ ); +} diff --git a/login/apps/login/src/components/idps/sign-in-with-apple.tsx b/login/apps/login/src/components/idps/sign-in-with-apple.tsx new file mode 100644 index 0000000000..17e3fc43bb --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-apple.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { forwardRef } from "react"; +import { Translated } from "../translated"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +export const SignInWithApple = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithApple(props, ref) { + const { children, name, ...restProps } = props; + + return ( + +
+
+ + Apple Logo + + +
+
+ {children ? ( + children + ) : ( + + {name ? ( + name + ) : ( + + )} + + )} +
+ ); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-azure-ad.tsx b/login/apps/login/src/components/idps/sign-in-with-azure-ad.tsx new file mode 100644 index 0000000000..3cd33708b6 --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-azure-ad.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { forwardRef } from "react"; +import { Translated } from "../translated"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +export const SignInWithAzureAd = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithAzureAd(props, ref) { + const { children, name, ...restProps } = props; + + return ( + +
+ + + + + + +
+ {children ? ( + children + ) : ( + + {name ? ( + name + ) : ( + + )} + + )} +
+ ); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-generic.tsx b/login/apps/login/src/components/idps/sign-in-with-generic.tsx new file mode 100644 index 0000000000..ab8f2f99be --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-generic.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { forwardRef } from "react"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +export const SignInWithGeneric = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithGeneric(props, ref) { + const { + children, + name = "", + className = "h-[50px] pl-20", + ...restProps + } = props; + return ( + + {children ? children : {name}} + + ); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-github.tsx b/login/apps/login/src/components/idps/sign-in-with-github.tsx new file mode 100644 index 0000000000..8800e66c3d --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-github.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { forwardRef } from "react"; +import { Translated } from "../translated"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +function GitHubLogo() { + return ( + <> + + + + + + + + ); +} + +export const SignInWithGithub = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithGithub(props, ref) { + const { children, name, ...restProps } = props; + + return ( + +
+ +
+ {children ? ( + children + ) : ( + + {name ? ( + name + ) : ( + + )} + + )} +
+ ); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-gitlab.test.tsx b/login/apps/login/src/components/idps/sign-in-with-gitlab.test.tsx new file mode 100644 index 0000000000..ab5bfda54d --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-gitlab.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { cleanup, render, screen } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; + +import { SignInWithGitlab } from "./sign-in-with-gitlab"; + +afterEach(cleanup); + +describe("", async () => { + const messages = { + idp: { + signInWithGitlab: "Sign in with GitLab", + }, + }; + + test("renders without crashing", () => { + const { container } = render( + + + , + ); + expect(container.firstChild).toBeDefined(); + }); + + test("displays the default text", () => { + render( + + + , + ); + const signInText = screen.getByText(/Sign in with Gitlab/i); + expect(signInText).toBeInTheDocument(); + }); + + test("displays the given text", () => { + render( + + + , + ); + const signInText = screen.getByText(/Gitlab/i); + expect(signInText).toBeInTheDocument(); + }); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-gitlab.tsx b/login/apps/login/src/components/idps/sign-in-with-gitlab.tsx new file mode 100644 index 0000000000..00f3712a90 --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-gitlab.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { forwardRef } from "react"; +import { Translated } from "../translated"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +export const SignInWithGitlab = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithGitlab(props, ref) { + const { children, name, ...restProps } = props; + + return ( + +
+ + + + + + +
+ {children ? ( + children + ) : ( + + {name ? ( + name + ) : ( + + )} + + )} +
+ ); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-google.test.tsx b/login/apps/login/src/components/idps/sign-in-with-google.test.tsx new file mode 100644 index 0000000000..953da21d94 --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-google.test.tsx @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { cleanup, render, screen } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; +import { SignInWithGoogle } from "./sign-in-with-google"; + +afterEach(cleanup); + +describe("", async () => { + const messages = { + idp: { + signInWithGoogle: "Sign in with Google", + }, + }; + + test("renders without crashing", () => { + const { container } = render( + + + , + ); + expect(container.firstChild).toBeDefined(); + }); + + test("displays the default text", () => { + render( + + + , + ); + const signInText = screen.getByText(/Sign in with Google/i); + expect(signInText).toBeInTheDocument(); + }); + + test("displays the given text", () => { + render( + + + , + ); + const signInText = screen.getByText(/Google/i); + expect(signInText).toBeInTheDocument(); + }); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-google.tsx b/login/apps/login/src/components/idps/sign-in-with-google.tsx new file mode 100644 index 0000000000..4759ad69c9 --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-google.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { forwardRef } from "react"; +import { Translated } from "../translated"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +export const SignInWithGoogle = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithGoogle(props, ref) { + const { children, name, ...restProps } = props; + + return ( + +
+ + + + + + + +
+ {children ? ( + children + ) : ( + + {name ? ( + name + ) : ( + + )} + + )} +
+ ); +}); diff --git a/login/apps/login/src/components/input.tsx b/login/apps/login/src/components/input.tsx new file mode 100644 index 0000000000..de19156b91 --- /dev/null +++ b/login/apps/login/src/components/input.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { CheckCircleIcon } from "@heroicons/react/24/solid"; +import { clsx } from "clsx"; +import { + ChangeEvent, + DetailedHTMLProps, + forwardRef, + InputHTMLAttributes, + ReactNode, +} from "react"; + +export type TextInputProps = DetailedHTMLProps< + InputHTMLAttributes, + HTMLInputElement +> & { + label: string; + suffix?: string; + placeholder?: string; + defaultValue?: string; + error?: string | ReactNode; + success?: string | ReactNode; + disabled?: boolean; + onChange?: (value: ChangeEvent) => void; + onBlur?: (value: ChangeEvent) => void; +}; + +const styles = (error: boolean, disabled: boolean) => + clsx({ + "h-[40px] mb-[2px] rounded p-[7px] bg-input-light-background dark:bg-input-dark-background transition-colors duration-300 grow": + true, + "border border-input-light-border dark:border-input-dark-border hover:border-black hover:dark:border-white focus:border-primary-light-500 focus:dark:border-primary-dark-500": + true, + "focus:outline-none focus:ring-0 text-base text-black dark:text-white placeholder:italic placeholder-gray-700 dark:placeholder-gray-700": + true, + "border border-warn-light-500 dark:border-warn-dark-500 hover:border-warn-light-500 hover:dark:border-warn-dark-500 focus:border-warn-light-500 focus:dark:border-warn-dark-500": + error, + "pointer-events-none text-gray-500 dark:text-gray-800 border border-input-light-border dark:border-input-dark-border hover:border-light-hoverborder hover:dark:border-hoverborder cursor-default": + disabled, + }); + +// eslint-disable-next-line react/display-name +export const TextInput = forwardRef( + ( + { + label, + placeholder, + defaultValue, + suffix, + required = false, + error, + disabled, + success, + onChange, + onBlur, + ...props + }, + ref, + ) => { + return ( + + ); + }, +); diff --git a/login/apps/login/src/components/language-provider.tsx b/login/apps/login/src/components/language-provider.tsx new file mode 100644 index 0000000000..21a53093bb --- /dev/null +++ b/login/apps/login/src/components/language-provider.tsx @@ -0,0 +1,13 @@ +import { NextIntlClientProvider } from "next-intl"; +import { getMessages } from "next-intl/server"; +import { ReactNode } from "react"; + +export async function LanguageProvider({ children }: { children: ReactNode }) { + const messages = await getMessages(); + + return ( + + {children} + + ); +} diff --git a/login/apps/login/src/components/language-switcher.tsx b/login/apps/login/src/components/language-switcher.tsx new file mode 100644 index 0000000000..67b54e58e3 --- /dev/null +++ b/login/apps/login/src/components/language-switcher.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { setLanguageCookie } from "@/lib/cookies"; +import { Lang, LANGS } from "@/lib/i18n"; +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "@headlessui/react"; +import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; +import clsx from "clsx"; +import { useLocale } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export function LanguageSwitcher() { + const currentLocale = useLocale(); + + const [selected, setSelected] = useState( + LANGS.find((l) => l.code === currentLocale) || LANGS[0], + ); + + const router = useRouter(); + + const handleChange = async (language: Lang) => { + setSelected(language); + const newLocale = language.code; + + await setLanguageCookie(newLocale); + + router.refresh(); + }; + + return ( +
+ + + {selected.name} + + + {LANGS.map((lang, index) => ( + + +
+ {lang.name} +
+
+ ))} +
+
+
+ ); +} diff --git a/login/apps/login/src/components/layout-providers.tsx b/login/apps/login/src/components/layout-providers.tsx new file mode 100644 index 0000000000..fee93d015e --- /dev/null +++ b/login/apps/login/src/components/layout-providers.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; +}; + +export function LayoutProviders({ children }: Props) { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + return ( +
{children}
+ ); +} diff --git a/login/apps/login/src/components/ldap-username-password-form.tsx b/login/apps/login/src/components/ldap-username-password-form.tsx new file mode 100644 index 0000000000..2f9824dff2 --- /dev/null +++ b/login/apps/login/src/components/ldap-username-password-form.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { createNewSessionForLDAP } from "@/lib/server/idp"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = { + loginName: string; + password: string; +}; + +type Props = { + idpId: string; + link: boolean; +}; + +export function LDAPUsernamePasswordForm({ idpId, link }: Props) { + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + }); + + const t = useTranslations("ldap"); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + async function submitUsernamePassword(values: Inputs) { + setError(""); + setLoading(true); + + const response = await createNewSessionForLDAP({ + idpId: idpId, + username: values.loginName, + password: values.password, + link: link, + }) + .catch(() => { + setError("Could not start LDAP flow"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + } + + return ( +
+ + +
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+ + ); +} diff --git a/login/apps/login/src/components/login-otp.tsx b/login/apps/login/src/components/login-otp.tsx new file mode 100644 index 0000000000..4ad6cced6a --- /dev/null +++ b/login/apps/login/src/components/login-otp.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { getNextUrl } from "@/lib/client"; +import { updateSession } from "@/lib/server/session"; +import { create } from "@zitadel/client"; +import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert, AlertType } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +// either loginName or sessionId must be provided +type Props = { + host: string | null; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; + method: string; + code?: string; + loginSettings?: LoginSettings; +}; + +type Inputs = { + code: string; +}; + +export function LoginOTP({ + host, + loginName, + sessionId, + requestId, + organization, + method, + code, + loginSettings, +}: Props) { + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + const initialized = useRef(false); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: code ? code : "", + }, + }); + + useEffect(() => { + if (!initialized.current && ["email", "sms"].includes(method) && !code) { + initialized.current = true; + setLoading(true); + updateSessionForOTPChallenge() + .catch((error) => { + setError(error); + return; + }) + .finally(() => { + setLoading(false); + }); + } + }, []); + + async function updateSessionForOTPChallenge() { + let challenges; + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + if (method === "email") { + challenges = create(RequestChallengesSchema, { + otpEmail: { + deliveryType: { + case: "sendCode", + value: host + ? { + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/otp/${method}?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}` + + (requestId ? `&requestId=${requestId}` : ""), + } + : {}, + }, + }, + }); + } + + if (method === "sms") { + challenges = create(RequestChallengesSchema, { + otpSms: {}, + }); + } + + setLoading(true); + const response = await updateSession({ + loginName, + sessionId, + organization, + challenges, + requestId, + }) + .catch(() => { + setError("Could not request OTP challenge"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + return response; + } + + async function submitCode(values: Inputs, organization?: string) { + setLoading(true); + + let body: any = { + code: values.code, + method, + }; + + if (organization) { + body.organization = organization; + } + + if (requestId) { + body.requestId = requestId; + } + + let checks; + + if (method === "sms") { + checks = create(ChecksSchema, { + otpSms: { code: values.code }, + }); + } + if (method === "email") { + checks = create(ChecksSchema, { + otpEmail: { code: values.code }, + }); + } + if (method === "time-based") { + checks = create(ChecksSchema, { + totp: { code: values.code }, + }); + } + + const response = await updateSession({ + loginName, + sessionId, + organization, + checks, + requestId, + }) + .catch(() => { + setError("Could not verify OTP code"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + return response; + } + + function setCodeAndContinue(values: Inputs, organization?: string) { + return submitCode(values, organization).then(async (response) => { + if (response && "sessionId" in response) { + setLoading(true); + // Wait for 2 seconds to avoid eventual consistency issues with an OTP code being verified in the /login endpoint + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const url = + requestId && response.sessionId + ? await getNextUrl( + { + sessionId: response.sessionId, + requestId: requestId, + organization: response.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ) + : response.factors?.user + ? await getNextUrl( + { + loginName: response.factors.user.loginName, + organization: response.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + + setLoading(false); + if (url) { + router.push(url); + } + } + }); + } + + return ( +
+ {["email", "sms"].includes(method) && ( + +
+ + + + +
+
+ )} +
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ ); +} diff --git a/login/apps/login/src/components/login-passkey.tsx b/login/apps/login/src/components/login-passkey.tsx new file mode 100644 index 0000000000..5a3b0b6496 --- /dev/null +++ b/login/apps/login/src/components/login-passkey.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; +import { sendPasskey } from "@/lib/server/passkeys"; +import { updateSession } from "@/lib/server/session"; +import { create, JsonObject } from "@zitadel/client"; +import { + RequestChallengesSchema, + UserVerificationRequirement, +} from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +// either loginName or sessionId must be provided +type Props = { + loginName?: string; + sessionId?: string; + requestId?: string; + altPassword: boolean; + login?: boolean; + organization?: string; +}; + +export function LoginPasskey({ + loginName, + sessionId, + requestId, + altPassword, + organization, + login = true, +}: Props) { + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + const initialized = useRef(false); + + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + setLoading(true); + updateSessionForChallenge() + .then((response) => { + const pK = + response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions + ?.publicKey; + + if (!pK) { + setError("Could not request passkey challenge"); + setLoading(false); + return; + } + + return submitLoginAndContinue(pK) + .catch((error) => { + setError(error); + return; + }) + .finally(() => { + setLoading(false); + }); + }) + .catch((error) => { + setError(error); + return; + }) + .finally(() => { + setLoading(false); + }); + } + }, []); + + async function updateSessionForChallenge( + userVerificationRequirement: number = login + ? UserVerificationRequirement.REQUIRED + : UserVerificationRequirement.DISCOURAGED, + ) { + setError(""); + setLoading(true); + const session = await updateSession({ + loginName, + sessionId, + organization, + challenges: create(RequestChallengesSchema, { + webAuthN: { + domain: "", + userVerificationRequirement, + }, + }), + requestId, + }) + .catch(() => { + setError("Could not request passkey challenge"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (session && "error" in session && session.error) { + setError(session.error); + return; + } + + return session; + } + + async function submitLogin(data: JsonObject) { + setLoading(true); + const response = await sendPasskey({ + loginName, + sessionId, + organization, + checks: { + webAuthN: { credentialAssertionData: data }, + } as Checks, + requestId, + }) + .catch(() => { + setError("Could not verify passkey"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + } + + async function submitLoginAndContinue( + publicKey: any, + ): Promise { + publicKey.challenge = coerceToArrayBuffer( + publicKey.challenge, + "publicKey.challenge", + ); + publicKey.allowCredentials.map((listItem: any) => { + listItem.id = coerceToArrayBuffer( + listItem.id, + "publicKey.allowCredentials.id", + ); + }); + + navigator.credentials + .get({ + publicKey, + }) + .then((assertedCredential: any) => { + if (!assertedCredential) { + setError("An error on retrieving passkey"); + return; + } + + const authData = new Uint8Array( + assertedCredential.response.authenticatorData, + ); + const clientDataJSON = new Uint8Array( + assertedCredential.response.clientDataJSON, + ); + const rawId = new Uint8Array(assertedCredential.rawId); + const sig = new Uint8Array(assertedCredential.response.signature); + const userHandle = new Uint8Array( + assertedCredential.response.userHandle, + ); + const data = { + id: assertedCredential.id, + rawId: coerceToBase64Url(rawId, "rawId"), + type: assertedCredential.type, + response: { + authenticatorData: coerceToBase64Url(authData, "authData"), + clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"), + signature: coerceToBase64Url(sig, "sig"), + userHandle: coerceToBase64Url(userHandle, "userHandle"), + }, + }; + + return submitLogin(data); + }) + .finally(() => { + setLoading(false); + }); + } + + return ( +
+ {error && ( +
+ {error} +
+ )} +
+ {altPassword ? ( + + ) : ( + + )} + + + +
+
+ ); +} diff --git a/login/apps/login/src/components/logo.tsx b/login/apps/login/src/components/logo.tsx new file mode 100644 index 0000000000..09819f2ac3 --- /dev/null +++ b/login/apps/login/src/components/logo.tsx @@ -0,0 +1,37 @@ +import Image from "next/image"; + +type Props = { + darkSrc?: string; + lightSrc?: string; + height?: number; + width?: number; +}; + +export function Logo({ lightSrc, darkSrc, height = 40, width = 147.5 }: Props) { + return ( + <> + {darkSrc && ( +
+ logo +
+ )} + {lightSrc && ( +
+ logo +
+ )} + + ); +} diff --git a/login/apps/login/src/components/password-complexity.test.tsx b/login/apps/login/src/components/password-complexity.test.tsx new file mode 100644 index 0000000000..090c95d397 --- /dev/null +++ b/login/apps/login/src/components/password-complexity.test.tsx @@ -0,0 +1,64 @@ +import { + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { PasswordComplexity } from "./password-complexity"; + +const matchesTitle = `Matches`; +const doesntMatchTitle = `Doesn't match`; + +describe("", () => { + describe.each` + settingsMinLength | password | expectSVGTitle + ${5} | ${"Password1!"} | ${matchesTitle} + ${30} | ${"Password1!"} | ${doesntMatchTitle} + ${0} | ${"Password1!"} | ${matchesTitle} + ${undefined} | ${"Password1!"} | ${false} + `( + `With settingsMinLength=$settingsMinLength, password=$password, expectSVGTitle=$expectSVGTitle`, + ({ settingsMinLength, password, expectSVGTitle }) => { + const feedbackElementLabel = /password length/i; + beforeEach(() => { + render( + , + ); + }); + afterEach(cleanup); + + if (expectSVGTitle === false) { + test(`should not render the feedback element`, async () => { + await waitFor(() => { + expect( + screen.queryByText(feedbackElementLabel), + ).not.toBeInTheDocument(); + }); + }); + } else { + test(`Should show one SVG with title ${expectSVGTitle}`, async () => { + await waitFor(async () => { + const svg = within( + screen.getByText(feedbackElementLabel) + .parentElement as HTMLElement, + ).findByRole("img"); + expect(await svg).toHaveTextContent(expectSVGTitle); + }); + }); + } + }, + ); +}); diff --git a/login/apps/login/src/components/password-complexity.tsx b/login/apps/login/src/components/password-complexity.tsx new file mode 100644 index 0000000000..40988984b6 --- /dev/null +++ b/login/apps/login/src/components/password-complexity.tsx @@ -0,0 +1,99 @@ +import { + lowerCaseValidator, + numberValidator, + symbolValidator, + upperCaseValidator, +} from "@/helpers/validators"; +import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; + +type Props = { + passwordComplexitySettings: PasswordComplexitySettings; + password: string; + equals: boolean; +}; + +const check = ( + + Matches + + +); +const cross = ( + + Doesn't match + + +); +const desc = + "text-14px leading-4 text-input-light-label dark:text-input-dark-label"; + +export function PasswordComplexity({ + passwordComplexitySettings, + password, + equals, +}: Props) { + const hasMinLength = password?.length >= passwordComplexitySettings.minLength; + const hasSymbol = symbolValidator(password); + const hasNumber = numberValidator(password); + const hasUppercase = upperCaseValidator(password); + const hasLowercase = lowerCaseValidator(password); + + return ( +
+ {passwordComplexitySettings.minLength != undefined ? ( +
+ {hasMinLength ? check : cross} + + Password length {passwordComplexitySettings.minLength.toString()} + +
+ ) : ( + + )} +
+ {hasSymbol ? check : cross} + has Symbol +
+
+ {hasNumber ? check : cross} + has Number +
+
+ {hasUppercase ? check : cross} + has uppercase +
+
+ {hasLowercase ? check : cross} + has lowercase +
+
+ {equals ? check : cross} + equals +
+
+ ); +} diff --git a/login/apps/login/src/components/password-form.tsx b/login/apps/login/src/components/password-form.tsx new file mode 100644 index 0000000000..3cd455c69c --- /dev/null +++ b/login/apps/login/src/components/password-form.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { resetPassword, sendPassword } from "@/lib/server/password"; +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert, AlertType } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = { + password: string; +}; + +type Props = { + loginSettings: LoginSettings | undefined; + loginName: string; + organization?: string; + requestId?: string; +}; + +export function PasswordForm({ + loginSettings, + loginName, + organization, + requestId, +}: Props) { + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + }); + + const [info, setInfo] = useState(""); + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + async function submitPassword(values: Inputs) { + setError(""); + setLoading(true); + + const response = await sendPassword({ + loginName, + organization, + checks: create(ChecksSchema, { + password: { password: values.password }, + }), + requestId, + }) + .catch(() => { + setError("Could not verify password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + } + + async function resetPasswordAndContinue() { + setError(""); + setInfo(""); + setLoading(true); + + const response = await resetPassword({ + loginName, + organization, + requestId, + }) + .catch(() => { + setError("Could not reset password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response) { + setError(response.error); + return; + } + + setInfo("Password was reset. Please check your email."); + + const params = new URLSearchParams({ + loginName: loginName, + }); + + if (organization) { + params.append("organization", organization); + } + + if (requestId) { + params.append("requestId", requestId); + } + + return router.push("/password/set?" + params); + } + + return ( +
+
+ + {!loginSettings?.hidePasswordReset && ( + + )} + + {loginName && ( + + )} +
+ + {info && ( +
+ {info} +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ ); +} diff --git a/login/apps/login/src/components/privacy-policy-checkboxes.tsx b/login/apps/login/src/components/privacy-policy-checkboxes.tsx new file mode 100644 index 0000000000..4ab0e33222 --- /dev/null +++ b/login/apps/login/src/components/privacy-policy-checkboxes.tsx @@ -0,0 +1,105 @@ +"use client"; +import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb"; +import Link from "next/link"; +import { useState } from "react"; +import { Checkbox } from "./checkbox"; +import { Translated } from "./translated"; + +type Props = { + legal: LegalAndSupportSettings; + onChange: (allAccepted: boolean) => void; +}; + +type AcceptanceState = { + tosAccepted: boolean; + privacyPolicyAccepted: boolean; +}; + +export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) { + const [acceptanceState, setAcceptanceState] = useState({ + tosAccepted: false, + privacyPolicyAccepted: false, + }); + + return ( + <> +

+ + {legal?.helpLink && ( + + + + + + + + )} +

+ {legal?.tosLink && ( +
+ { + setAcceptanceState({ + ...acceptanceState, + tosAccepted: checked, + }); + onChange(checked && acceptanceState.privacyPolicyAccepted); + }} + data-testid="privacy-policy-checkbox" + /> + +
+

+ + + +

+
+
+ )} + {legal?.privacyPolicyLink && ( +
+ { + setAcceptanceState({ + ...acceptanceState, + privacyPolicyAccepted: checked, + }); + onChange(checked && acceptanceState.tosAccepted); + }} + data-testid="tos-checkbox" + /> + +
+

+ + + +

+
+
+ )} + + ); +} diff --git a/login/apps/login/src/components/register-form-idp-incomplete.tsx b/login/apps/login/src/components/register-form-idp-incomplete.tsx new file mode 100644 index 0000000000..b8a7765c9c --- /dev/null +++ b/login/apps/login/src/components/register-form-idp-incomplete.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { registerUserAndLinkToIDP } from "@/lib/server/register"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = + | { + firstname: string; + lastname: string; + email: string; + } + | FieldValues; + +type Props = { + organization: string; + requestId?: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + defaultValues?: { + firstname?: string; + lastname?: string; + email?: string; + }; + idpUserId: string; + idpId: string; + idpUserName: string; +}; + +export function RegisterFormIDPIncomplete({ + organization, + requestId, + idpIntent, + defaultValues, + idpUserId, + idpId, + idpUserName, +}: Props) { + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + email: defaultValues?.email ?? "", + firstname: defaultValues?.firstname ?? "", + lastname: defaultValues?.lastname ?? "", + }, + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const router = useRouter(); + + async function submitAndRegister(values: Inputs) { + setLoading(true); + const response = await registerUserAndLinkToIDP({ + idpId: idpId, + idpUserName: idpUserName, + idpUserId: idpUserId, + email: values.email, + firstName: values.firstname, + lastName: values.lastname, + organization: organization, + requestId: requestId, + idpIntent: idpIntent, + }) + .catch(() => { + setError("Could not register user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + + return response; + } + + const { errors } = formState; + + return ( +
+
+
+ +
+
+ +
+
+ +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ ); +} diff --git a/login/apps/login/src/components/register-form.tsx b/login/apps/login/src/components/register-form.tsx new file mode 100644 index 0000000000..6217bbcbb9 --- /dev/null +++ b/login/apps/login/src/components/register-form.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { registerUser } from "@/lib/server/register"; +import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb"; +import { + LoginSettings, + PasskeysType, +} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Alert, AlertType } from "./alert"; +import { + AuthenticationMethod, + AuthenticationMethodRadio, + methods, +} from "./authentication-method-radio"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { PrivacyPolicyCheckboxes } from "./privacy-policy-checkboxes"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = + | { + firstname: string; + lastname: string; + email: string; + } + | FieldValues; + +type Props = { + legal: LegalAndSupportSettings; + firstname?: string; + lastname?: string; + email?: string; + organization: string; + requestId?: string; + loginSettings?: LoginSettings; + idpCount: number; +}; + +export function RegisterForm({ + legal, + email, + firstname, + lastname, + organization, + requestId, + loginSettings, + idpCount = 0, +}: Props) { + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + email: email ?? "", + firstName: firstname ?? "", + lastname: lastname ?? "", + }, + }); + + const [loading, setLoading] = useState(false); + const [selected, setSelected] = useState(methods[0]); + const [error, setError] = useState(""); + + const router = useRouter(); + + async function submitAndRegister(values: Inputs) { + setLoading(true); + const response = await registerUser({ + email: values.email, + firstName: values.firstname, + lastName: values.lastname, + organization: organization, + requestId: requestId, + }) + .catch(() => { + setError("Could not register user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + + return response; + } + + async function submitAndContinue( + value: Inputs, + withPassword: boolean = false, + ) { + const registerParams: any = value; + + if (organization) { + registerParams.organization = organization; + } + + if (requestId) { + registerParams.requestId = requestId; + } + + // redirect user to /register/password if password is chosen + if (withPassword) { + return router.push( + `/register/password?` + new URLSearchParams(registerParams), + ); + } else { + return submitAndRegister(value); + } + } + + const { errors } = formState; + + const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false); + return ( +
+
+
+ +
+
+ +
+
+ +
+
+ {legal && ( + + )} + {/* show chooser if both methods are allowed */} + {loginSettings && + loginSettings.allowUsernamePassword && + loginSettings.passkeysType == PasskeysType.ALLOWED && ( + <> +

+ +

+ +
+ +
+ + )} + {!loginSettings?.allowUsernamePassword && + loginSettings?.passkeysType !== PasskeysType.ALLOWED && + (!loginSettings?.allowExternalIdp || !idpCount) && ( +
+ + + +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + +
+ + ); +} diff --git a/login/apps/login/src/components/register-passkey.tsx b/login/apps/login/src/components/register-passkey.tsx new file mode 100644 index 0000000000..e21e1acdbb --- /dev/null +++ b/login/apps/login/src/components/register-passkey.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; +import { + registerPasskeyLink, + verifyPasskeyRegistration, +} from "@/lib/server/passkeys"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = {}; + +type Props = { + sessionId: string; + isPrompt: boolean; + requestId?: string; + organization?: string; +}; + +export function RegisterPasskey({ + sessionId, + isPrompt, + organization, + requestId, +}: Props) { + const { handleSubmit, formState } = useForm({ + mode: "onBlur", + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + async function submitVerify( + passkeyId: string, + passkeyName: string, + publicKeyCredential: any, + sessionId: string, + ) { + setLoading(true); + const response = await verifyPasskeyRegistration({ + passkeyId, + passkeyName, + publicKeyCredential, + sessionId, + }) + .catch(() => { + setError("Could not verify Passkey"); + return; + }) + .finally(() => { + setLoading(false); + }); + + return response; + } + + async function submitRegisterAndContinue(): Promise { + setLoading(true); + const resp = await registerPasskeyLink({ + sessionId, + }) + .catch(() => { + setError("Could not register passkey"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (!resp) { + setError("An error on registering passkey"); + return; + } + + if ("error" in resp && resp.error) { + setError(resp.error); + return; + } + + if (!("passkeyId" in resp)) { + setError("An error on registering passkey"); + return; + } + + const passkeyId = resp.passkeyId; + const options: CredentialCreationOptions = + (resp.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? + {}; + + if (!options.publicKey) { + setError("An error on registering passkey"); + return; + } + + options.publicKey.challenge = coerceToArrayBuffer( + options.publicKey.challenge, + "challenge", + ); + options.publicKey.user.id = coerceToArrayBuffer( + options.publicKey.user.id, + "userid", + ); + if (options.publicKey.excludeCredentials) { + options.publicKey.excludeCredentials.map((cred: any) => { + cred.id = coerceToArrayBuffer( + cred.id as string, + "excludeCredentials.id", + ); + return cred; + }); + } + + const credentials = await navigator.credentials.create(options); + + if ( + !credentials || + !(credentials as any).response?.attestationObject || + !(credentials as any).response?.clientDataJSON || + !(credentials as any).rawId + ) { + setError("An error on registering passkey"); + return; + } + + const attestationObject = (credentials as any).response.attestationObject; + const clientDataJSON = (credentials as any).response.clientDataJSON; + const rawId = (credentials as any).rawId; + + const data = { + id: credentials.id, + rawId: coerceToBase64Url(rawId, "rawId"), + type: credentials.type, + response: { + attestationObject: coerceToBase64Url( + attestationObject, + "attestationObject", + ), + clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"), + }, + }; + + const verificationResponse = await submitVerify( + passkeyId, + "", + data, + sessionId, + ); + + if (!verificationResponse) { + setError("Could not verify Passkey!"); + return; + } + + continueAndLogin(); + } + + function continueAndLogin() { + const params = new URLSearchParams(); + + if (organization) { + params.set("organization", organization); + } + + if (requestId) { + params.set("requestId", requestId); + } + + params.set("sessionId", sessionId); + + router.push("/passkey?" + params); + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ {isPrompt ? ( + + ) : ( + + )} + + + +
+
+ ); +} diff --git a/login/apps/login/src/components/register-u2f.tsx b/login/apps/login/src/components/register-u2f.tsx new file mode 100644 index 0000000000..e72bf1fc69 --- /dev/null +++ b/login/apps/login/src/components/register-u2f.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; +import { getNextUrl } from "@/lib/client"; +import { addU2F, verifyU2F } from "@/lib/server/u2f"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { RegisterU2FResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Props = { + loginName?: string; + sessionId: string; + requestId?: string; + organization?: string; + checkAfter: boolean; + loginSettings?: LoginSettings; +}; + +export function RegisterU2f({ + loginName, + sessionId, + organization, + requestId, + checkAfter, + loginSettings, +}: Props) { + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + async function submitVerify( + u2fId: string, + passkeyName: string, + publicKeyCredential: any, + sessionId: string, + ) { + setError(""); + setLoading(true); + const response = await verifyU2F({ + u2fId, + passkeyName, + publicKeyCredential, + sessionId, + }) + .catch(() => { + setError("An error on verifying passkey occurred"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response?.error); + return; + } + + return response; + } + + async function submitRegisterAndContinue(): Promise { + setError(""); + setLoading(true); + const response = await addU2F({ + sessionId, + }) + .catch(() => { + setError("An error on registering passkey"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response?.error); + return; + } + + if (!response || !("u2fId" in response)) { + setError("An error on registering passkey"); + return; + } + + const u2fResponse = response as unknown as RegisterU2FResponse; + + const u2fId = u2fResponse.u2fId; + const options: CredentialCreationOptions = + (u2fResponse?.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? + {}; + + if (options.publicKey) { + options.publicKey.challenge = coerceToArrayBuffer( + options.publicKey.challenge, + "challenge", + ); + options.publicKey.user.id = coerceToArrayBuffer( + options.publicKey.user.id, + "userid", + ); + if (options.publicKey.excludeCredentials) { + options.publicKey.excludeCredentials.map((cred: any) => { + cred.id = coerceToArrayBuffer( + cred.id as string, + "excludeCredentials.id", + ); + return cred; + }); + } + + const resp = await navigator.credentials.create(options); + + if ( + !resp || + !(resp as any).response.attestationObject || + !(resp as any).response.clientDataJSON || + !(resp as any).rawId + ) { + setError("An error on registering passkey"); + return; + } + + const attestationObject = (resp as any).response.attestationObject; + const clientDataJSON = (resp as any).response.clientDataJSON; + const rawId = (resp as any).rawId; + + const data = { + id: resp.id, + rawId: coerceToBase64Url(rawId, "rawId"), + type: resp.type, + response: { + attestationObject: coerceToBase64Url( + attestationObject, + "attestationObject", + ), + clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"), + }, + }; + + const submitResponse = await submitVerify(u2fId, "", data, sessionId); + + if (!submitResponse) { + setError("An error on verifying passkey"); + return; + } + + if (checkAfter) { + const paramsToContinue = new URLSearchParams({}); + + if (sessionId) { + paramsToContinue.append("sessionId", sessionId); + } + if (loginName) { + paramsToContinue.append("loginName", loginName); + } + if (organization) { + paramsToContinue.append("organization", organization); + } + if (requestId) { + paramsToContinue.append("requestId", requestId); + } + + return router.push(`/u2f?` + paramsToContinue); + } else { + const url = + requestId && sessionId + ? await getNextUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : loginName + ? await getNextUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + if (url) { + return router.push(url); + } + } + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + + + +
+
+ ); +} diff --git a/login/apps/login/src/components/self-service-menu.tsx b/login/apps/login/src/components/self-service-menu.tsx new file mode 100644 index 0000000000..449c1dda1f --- /dev/null +++ b/login/apps/login/src/components/self-service-menu.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; + +export function SelfServiceMenu({ sessionId }: { sessionId: string }) { + const list: any[] = []; + + // if (!!config.selfservice.change_password.enabled) { + // list.push({ + // link: + // `/me/change-password?` + + // new URLSearchParams({ + // sessionId: sessionId, + // }), + // name: "Change password", + // }); + // } + + return ( +
+ {list.map((menuitem, index) => { + return ( + + ); + })} +
+ ); +} + +const SelfServiceItem = ({ name, link }: { name: string; link: string }) => { + return ( + + {name} + + ); +}; diff --git a/login/apps/login/src/components/session-clear-item.tsx b/login/apps/login/src/components/session-clear-item.tsx new file mode 100644 index 0000000000..81930b11b3 --- /dev/null +++ b/login/apps/login/src/components/session-clear-item.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { clearSession } from "@/lib/server/session"; +import { timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import moment from "moment"; +import { useLocale } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Avatar } from "./avatar"; +import { isSessionValid } from "./session-item"; +import { Translated } from "./translated"; + +export function SessionClearItem({ + session, + reload, +}: { + session: Session; + reload: () => void; +}) { + const currentLocale = useLocale(); + moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale); + + const [loading, setLoading] = useState(false); + + async function clearSessionId(id: string) { + setLoading(true); + const response = await clearSession({ + sessionId: id, + }) + .catch((error) => { + setError(error.message); + return; + }) + .finally(() => { + setLoading(false); + }); + + return response; + } + + const { valid, verifiedAt } = isSessionValid(session); + + const [error, setError] = useState(null); + + const router = useRouter(); + + return ( + + ); +} diff --git a/login/apps/login/src/components/session-item.tsx b/login/apps/login/src/components/session-item.tsx new file mode 100644 index 0000000000..94e7a19da5 --- /dev/null +++ b/login/apps/login/src/components/session-item.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { sendLoginname } from "@/lib/server/loginname"; +import { clearSession, continueWithSession } from "@/lib/server/session"; +import { XCircleIcon } from "@heroicons/react/24/outline"; +import { Timestamp, timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import moment from "moment"; +import { useLocale } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Avatar } from "./avatar"; + +export function isSessionValid(session: Partial): { + valid: boolean; + verifiedAt?: Timestamp; +} { + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const validIDP = session?.factors?.intent?.verifiedAt; + + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate) > new Date() + : true; + + const verifiedAt = validPassword || validPasskey || validIDP; + const valid = !!((validPassword || validPasskey || validIDP) && stillValid); + + return { valid, verifiedAt }; +} + +export function SessionItem({ + session, + reload, + requestId, +}: { + session: Session; + reload: () => void; + requestId?: string; +}) { + const currentLocale = useLocale(); + moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale); + + const [loading, setLoading] = useState(false); + + async function clearSessionId(id: string) { + setLoading(true); + const response = await clearSession({ + sessionId: id, + }) + .catch((error) => { + setError(error.message); + return; + }) + .finally(() => { + setLoading(false); + }); + + return response; + } + + const { valid, verifiedAt } = isSessionValid(session); + + const [error, setError] = useState(null); + + const router = useRouter(); + + return ( + + ); +} diff --git a/login/apps/login/src/components/sessions-clear-list.tsx b/login/apps/login/src/components/sessions-clear-list.tsx new file mode 100644 index 0000000000..5989948725 --- /dev/null +++ b/login/apps/login/src/components/sessions-clear-list.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { clearSession } from "@/lib/server/session"; +import { timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { redirect, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Alert, AlertType } from "./alert"; +import { SessionClearItem } from "./session-clear-item"; +import { Translated } from "./translated"; + +type Props = { + sessions: Session[]; + postLogoutRedirectUri?: string; + logoutHint?: string; + organization?: string; +}; + +export function SessionsClearList({ + sessions, + logoutHint, + postLogoutRedirectUri, + organization, +}: Props) { + const [list, setList] = useState(sessions); + const router = useRouter(); + + async function clearHintedSession() { + console.log("Clearing session for login hint:", logoutHint); + // If a login hint is provided, we logout that specific session + const sessionIdToBeCleared = sessions.find((session) => { + return session.factors?.user?.loginName === logoutHint; + })?.id; + + if (sessionIdToBeCleared) { + const clearSessionResponse = await clearSession({ + sessionId: sessionIdToBeCleared, + }).catch((error) => { + console.error("Error clearing session:", error); + return; + }); + + if (!clearSessionResponse) { + console.error("Failed to clear session for login hint:", logoutHint); + } + + if (postLogoutRedirectUri) { + return redirect(postLogoutRedirectUri); + } + + const params = new URLSearchParams(); + + if (organization) { + params.set("organization", organization); + } + + return router.push("/logout/success?" + params); + } else { + console.warn(`No session found for login hint: ${logoutHint}`); + } + } + + useEffect(() => { + if (logoutHint) { + clearHintedSession(); + } + }, []); + + return sessions ? ( +
+ {list + .filter((session) => session?.factors?.user?.loginName) + // sort by change date descending + .sort((a, b) => { + const dateA = a.changeDate + ? timestampDate(a.changeDate).getTime() + : 0; + const dateB = b.changeDate + ? timestampDate(b.changeDate).getTime() + : 0; + return dateB - dateA; + }) + // TODO: add sorting to move invalid sessions to the bottom + .map((session, index) => { + return ( + { + setList(list.filter((s) => s.id !== session.id)); + if (postLogoutRedirectUri) { + router.push(postLogoutRedirectUri); + } + }} + key={"session-" + index} + /> + ); + })} + {list.length === 0 && ( + + + + )} +
+ ) : ( + + + + ); +} diff --git a/login/apps/login/src/components/sessions-list.tsx b/login/apps/login/src/components/sessions-list.tsx new file mode 100644 index 0000000000..a3a1f8ed94 --- /dev/null +++ b/login/apps/login/src/components/sessions-list.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { useState } from "react"; +import { Alert } from "./alert"; +import { SessionItem } from "./session-item"; +import { Translated } from "./translated"; + +type Props = { + sessions: Session[]; + requestId?: string; +}; + +export function SessionsList({ sessions, requestId }: Props) { + const [list, setList] = useState(sessions); + return sessions ? ( +
+ {list + .filter((session) => session?.factors?.user?.loginName) + // sort by change date descending + .sort((a, b) => { + const dateA = a.changeDate + ? timestampDate(a.changeDate).getTime() + : 0; + const dateB = b.changeDate + ? timestampDate(b.changeDate).getTime() + : 0; + return dateB - dateA; + }) + // TODO: add sorting to move invalid sessions to the bottom + .map((session, index) => { + return ( + { + setList(list.filter((s) => s.id !== session.id)); + }} + key={"session-" + index} + /> + ); + })} +
+ ) : ( + + + + ); +} diff --git a/login/apps/login/src/components/set-password-form.tsx b/login/apps/login/src/components/set-password-form.tsx new file mode 100644 index 0000000000..2c3db8dbf2 --- /dev/null +++ b/login/apps/login/src/components/set-password-form.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { + lowerCaseValidator, + numberValidator, + symbolValidator, + upperCaseValidator, +} from "@/helpers/validators"; +import { + changePassword, + resetPassword, + sendPassword, +} from "@/lib/server/password"; +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Alert, AlertType } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { PasswordComplexity } from "./password-complexity"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = + | { + code: string; + password: string; + confirmPassword: string; + } + | FieldValues; + +type Props = { + code?: string; + passwordComplexitySettings: PasswordComplexitySettings; + loginName: string; + userId: string; + organization?: string; + requestId?: string; + codeRequired: boolean; +}; + +export function SetPasswordForm({ + passwordComplexitySettings, + organization, + requestId, + loginName, + userId, + code, + codeRequired, +}: Props) { + const { register, handleSubmit, watch, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: code ?? "", + }, + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const router = useRouter(); + + async function resendCode() { + setError(""); + setLoading(true); + + const response = await resetPassword({ + loginName, + organization, + requestId, + }) + .catch(() => { + setError("Could not reset password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response) { + setError(response.error); + return; + } + } + + async function submitPassword(values: Inputs) { + setLoading(true); + let payload: { userId: string; password: string; code?: string } = { + userId: userId, + password: values.password, + }; + + // this is not required for initial password setup + if (codeRequired) { + payload = { ...payload, code: values.code }; + } + + const changeResponse = await changePassword(payload) + .catch(() => { + setError("Could not set password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (changeResponse && "error" in changeResponse) { + setError(changeResponse.error); + return; + } + + if (!changeResponse) { + setError("Could not set password"); + return; + } + + const params = new URLSearchParams({}); + + if (loginName) { + params.append("loginName", loginName); + } + if (organization) { + params.append("organization", organization); + } + + await new Promise((resolve) => setTimeout(resolve, 2000)); // Wait for a second to avoid eventual consistency issues with an initial password being set + + const passwordResponse = await sendPassword({ + loginName, + organization, + checks: create(ChecksSchema, { + password: { password: values.password }, + }), + requestId, + }) + .catch(() => { + setError("Could not verify password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if ( + passwordResponse && + "error" in passwordResponse && + passwordResponse.error + ) { + setError(passwordResponse.error); + return; + } + + if ( + passwordResponse && + "redirect" in passwordResponse && + passwordResponse.redirect + ) { + return router.push(passwordResponse.redirect); + } + + return; + } + + const { errors } = formState; + + const watchPassword = watch("password", ""); + const watchConfirmPassword = watch("confirmPassword", ""); + + const hasMinLength = + passwordComplexitySettings && + watchPassword?.length >= passwordComplexitySettings.minLength; + const hasSymbol = symbolValidator(watchPassword); + const hasNumber = numberValidator(watchPassword); + const hasUppercase = upperCaseValidator(watchPassword); + const hasLowercase = lowerCaseValidator(watchPassword); + + const policyIsValid = + passwordComplexitySettings && + (passwordComplexitySettings.requiresLowercase ? hasLowercase : true) && + (passwordComplexitySettings.requiresNumber ? hasNumber : true) && + (passwordComplexitySettings.requiresUppercase ? hasUppercase : true) && + (passwordComplexitySettings.requiresSymbol ? hasSymbol : true) && + hasMinLength; + + return ( +
+
+ {codeRequired && ( + +
+ + + + +
+
+ )} + {codeRequired && ( +
+ +
+ )} +
+ +
+
+ +
+
+ + {passwordComplexitySettings && ( + + )} + + {error && {error}} + +
+ + +
+ + ); +} diff --git a/login/apps/login/src/components/set-register-password-form.tsx b/login/apps/login/src/components/set-register-password-form.tsx new file mode 100644 index 0000000000..7660e60753 --- /dev/null +++ b/login/apps/login/src/components/set-register-password-form.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { + lowerCaseValidator, + numberValidator, + symbolValidator, + upperCaseValidator, +} from "@/helpers/validators"; +import { registerUser } from "@/lib/server/register"; +import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { PasswordComplexity } from "./password-complexity"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = + | { + password: string; + confirmPassword: string; + } + | FieldValues; + +type Props = { + passwordComplexitySettings: PasswordComplexitySettings; + email: string; + firstname: string; + lastname: string; + organization: string; + requestId?: string; +}; + +export function SetRegisterPasswordForm({ + passwordComplexitySettings, + email, + firstname, + lastname, + organization, + requestId, +}: Props) { + const { register, handleSubmit, watch, formState } = useForm({ + mode: "onBlur", + defaultValues: { + email: email ?? "", + firstname: firstname ?? "", + lastname: lastname ?? "", + }, + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const router = useRouter(); + + async function submitRegister(values: Inputs) { + setLoading(true); + const response = await registerUser({ + email: email, + firstName: firstname, + lastName: lastname, + organization: organization, + requestId: requestId, + password: values.password, + }) + .catch(() => { + setError("Could not register user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + } + + const { errors } = formState; + + const watchPassword = watch("password", ""); + const watchConfirmPassword = watch("confirmPassword", ""); + + const hasMinLength = + passwordComplexitySettings && + watchPassword?.length >= passwordComplexitySettings.minLength; + const hasSymbol = symbolValidator(watchPassword); + const hasNumber = numberValidator(watchPassword); + const hasUppercase = upperCaseValidator(watchPassword); + const hasLowercase = lowerCaseValidator(watchPassword); + + const policyIsValid = + passwordComplexitySettings && + (passwordComplexitySettings.requiresLowercase ? hasLowercase : true) && + (passwordComplexitySettings.requiresNumber ? hasNumber : true) && + (passwordComplexitySettings.requiresUppercase ? hasUppercase : true) && + (passwordComplexitySettings.requiresSymbol ? hasSymbol : true) && + hasMinLength; + + return ( +
+
+
+ +
+
+ +
+
+ + {passwordComplexitySettings && ( + + )} + + {error && {error}} + +
+ + +
+ + ); +} diff --git a/login/apps/login/src/components/sign-in-with-idp.tsx b/login/apps/login/src/components/sign-in-with-idp.tsx new file mode 100644 index 0000000000..ec9cfb36f8 --- /dev/null +++ b/login/apps/login/src/components/sign-in-with-idp.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { idpTypeToSlug } from "@/lib/idp"; +import { redirectToIdp } from "@/lib/server/idp"; +import { + IdentityProvider, + IdentityProviderType, +} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { ReactNode, useActionState } from "react"; +import { Alert } from "./alert"; +import { SignInWithIdentityProviderProps } from "./idps/base-button"; +import { SignInWithApple } from "./idps/sign-in-with-apple"; +import { SignInWithAzureAd } from "./idps/sign-in-with-azure-ad"; +import { SignInWithGeneric } from "./idps/sign-in-with-generic"; +import { SignInWithGithub } from "./idps/sign-in-with-github"; +import { SignInWithGitlab } from "./idps/sign-in-with-gitlab"; +import { SignInWithGoogle } from "./idps/sign-in-with-google"; +import { Translated } from "./translated"; + +export interface SignInWithIDPProps { + children?: ReactNode; + identityProviders: IdentityProvider[]; + requestId?: string; + organization?: string; + linkOnly?: boolean; +} + +export function SignInWithIdp({ + identityProviders, + requestId, + organization, + linkOnly, +}: Readonly) { + const [state, action, _isPending] = useActionState(redirectToIdp, {}); + + const renderIDPButton = (idp: IdentityProvider, index: number) => { + const { id, name, type } = idp; + + const components: Partial< + Record< + IdentityProviderType, + (props: SignInWithIdentityProviderProps) => ReactNode + > + > = { + [IdentityProviderType.APPLE]: SignInWithApple, + [IdentityProviderType.OAUTH]: SignInWithGeneric, + [IdentityProviderType.OIDC]: SignInWithGeneric, + [IdentityProviderType.GITHUB]: SignInWithGithub, + [IdentityProviderType.GITHUB_ES]: SignInWithGithub, + [IdentityProviderType.AZURE_AD]: SignInWithAzureAd, + [IdentityProviderType.GOOGLE]: (props) => ( + + ), + [IdentityProviderType.GITLAB]: SignInWithGitlab, + [IdentityProviderType.GITLAB_SELF_HOSTED]: SignInWithGitlab, + [IdentityProviderType.SAML]: SignInWithGeneric, + [IdentityProviderType.LDAP]: SignInWithGeneric, + [IdentityProviderType.JWT]: SignInWithGeneric, + }; + + const Component = components[type]; + return Component ? ( +
+ + + + + + + + ) : null; + }; + + return ( +
+

+ +

+ {!!identityProviders.length && identityProviders?.map(renderIDPButton)} + {state?.error && ( +
+ {state?.error} +
+ )} +
+ ); +} + +SignInWithIdp.displayName = "SignInWithIDP"; diff --git a/login/apps/login/src/components/skeleton-card.tsx b/login/apps/login/src/components/skeleton-card.tsx new file mode 100644 index 0000000000..80b3793e8f --- /dev/null +++ b/login/apps/login/src/components/skeleton-card.tsx @@ -0,0 +1,16 @@ +import { clsx } from "clsx"; + +export const SkeletonCard = ({ isLoading }: { isLoading?: boolean }) => ( +
+
+
+
+
+
+
+); diff --git a/login/apps/login/src/components/skeleton.tsx b/login/apps/login/src/components/skeleton.tsx new file mode 100644 index 0000000000..548953d278 --- /dev/null +++ b/login/apps/login/src/components/skeleton.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from "react"; + +export function Skeleton({ children }: { children?: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/login/apps/login/src/components/spinner.tsx b/login/apps/login/src/components/spinner.tsx new file mode 100644 index 0000000000..5ed2f04c80 --- /dev/null +++ b/login/apps/login/src/components/spinner.tsx @@ -0,0 +1,22 @@ +import { FC } from "react"; + +export const Spinner: FC<{ className?: string }> = ({ className = "" }) => { + return ( + + + + + ); +}; diff --git a/login/apps/login/src/components/state-badge.tsx b/login/apps/login/src/components/state-badge.tsx new file mode 100644 index 0000000000..00151390bf --- /dev/null +++ b/login/apps/login/src/components/state-badge.tsx @@ -0,0 +1,40 @@ +import { clsx } from "clsx"; +import { ReactNode } from "react"; + +export enum BadgeState { + Info = "info", + Error = "error", + Success = "success", + Alert = "alert", +} + +export type StateBadgeProps = { + state: BadgeState; + children: ReactNode; + evenPadding?: boolean; +}; + +const getBadgeClasses = (state: BadgeState, evenPadding: boolean) => + clsx({ + "w-fit border-box h-18.5px flex flex-row items-center whitespace-nowrap tracking-wider leading-4 items-center justify-center px-2 py-2px text-12px rounded-full shadow-sm": + true, + "bg-state-success-light-background text-state-success-light-color dark:bg-state-success-dark-background dark:text-state-success-dark-color ": + state === BadgeState.Success, + "bg-state-neutral-light-background text-state-neutral-light-color dark:bg-state-neutral-dark-background dark:text-state-neutral-dark-color": + state === BadgeState.Info, + "bg-state-error-light-background text-state-error-light-color dark:bg-state-error-dark-background dark:text-state-error-dark-color": + state === BadgeState.Error, + "bg-state-alert-light-background text-state-alert-light-color dark:bg-state-alert-dark-background dark:text-state-alert-dark-color": + state === BadgeState.Alert, + "p-[2px]": evenPadding, + }); + +export function StateBadge({ + state = BadgeState.Success, + evenPadding = false, + children, +}: StateBadgeProps) { + return ( + {children} + ); +} diff --git a/login/apps/login/src/components/tab-group.tsx b/login/apps/login/src/components/tab-group.tsx new file mode 100644 index 0000000000..afa625d345 --- /dev/null +++ b/login/apps/login/src/components/tab-group.tsx @@ -0,0 +1,16 @@ +import { Tab } from "@/components/tab"; + +export type Item = { + text: string; + slug?: string; +}; + +export const TabGroup = ({ path, items }: { path: string; items: Item[] }) => { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +}; diff --git a/login/apps/login/src/components/tab.tsx b/login/apps/login/src/components/tab.tsx new file mode 100644 index 0000000000..bd82931b5a --- /dev/null +++ b/login/apps/login/src/components/tab.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { Item } from "@/components/tab-group"; +import { clsx } from "clsx"; +import Link from "next/link"; +import { useSelectedLayoutSegment } from "next/navigation"; + +export const Tab = ({ + path, + item: { slug, text }, +}: { + path: string; + item: Item; +}) => { + const segment = useSelectedLayoutSegment(); + const href = slug ? path + "/" + slug : path; + const isActive = + // Example home pages e.g. `/layouts` + (!slug && segment === null) || + // Nested pages e.g. `/layouts/electronics` + segment === slug; + + return ( + + {text} + + ); +}; diff --git a/login/apps/login/src/components/theme-provider.tsx b/login/apps/login/src/components/theme-provider.tsx new file mode 100644 index 0000000000..a8a72f86a6 --- /dev/null +++ b/login/apps/login/src/components/theme-provider.tsx @@ -0,0 +1,16 @@ +"use client"; +import { ThemeProvider as ThemeP } from "next-themes"; +import { ReactNode } from "react"; + +export function ThemeProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/login/apps/login/src/components/theme-wrapper.tsx b/login/apps/login/src/components/theme-wrapper.tsx new file mode 100644 index 0000000000..314c3a2ef0 --- /dev/null +++ b/login/apps/login/src/components/theme-wrapper.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { setTheme } from "@/helpers/colors"; +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { ReactNode, useEffect } from "react"; + +type Props = { + branding: BrandingSettings | undefined; + children: ReactNode; +}; + +export const ThemeWrapper = ({ children, branding }: Props) => { + useEffect(() => { + setTheme(document, branding); + }, [branding]); + + return
{children}
; +}; diff --git a/login/apps/login/src/components/theme.tsx b/login/apps/login/src/components/theme.tsx new file mode 100644 index 0000000000..86d39476ff --- /dev/null +++ b/login/apps/login/src/components/theme.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { MoonIcon, SunIcon } from "@heroicons/react/24/outline"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +export function Theme() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + const isDark = resolvedTheme === "dark"; + + // useEffect only runs on the client, so now we can safely show the UI + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/login/apps/login/src/components/totp-register.tsx b/login/apps/login/src/components/totp-register.tsx new file mode 100644 index 0000000000..ea40fffbf0 --- /dev/null +++ b/login/apps/login/src/components/totp-register.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { getNextUrl } from "@/lib/client"; +import { verifyTOTP } from "@/lib/server/verify"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { QRCodeSVG } from "qrcode.react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { Button, ButtonVariants } from "./button"; +import { CopyToClipboard } from "./copy-to-clipboard"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = { + code: string; +}; + +type Props = { + uri: string; + secret: string; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; + checkAfter?: boolean; + loginSettings?: LoginSettings; +}; +export function TotpRegister({ + uri, + secret, + loginName, + sessionId, + requestId, + organization, + checkAfter, + loginSettings, +}: Props) { + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: "", + }, + }); + + async function continueWithCode(values: Inputs) { + setLoading(true); + return verifyTOTP(values.code, loginName, organization) + .then(async () => { + // if attribute is set, validate MFA after it is setup, otherwise proceed as usual (when mfa is enforced to login) + if (checkAfter) { + const params = new URLSearchParams({}); + + if (loginName) { + params.append("loginName", loginName); + } + if (requestId) { + params.append("requestId", requestId); + } + if (organization) { + params.append("organization", organization); + } + + return router.push(`/otp/time-based?` + params); + } else { + const url = + requestId && sessionId + ? await getNextUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : loginName + ? await getNextUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + + if (url) { + return router.push(url); + } + } + }) + .catch((e) => { + setError(e.message); + return; + }) + .finally(() => { + setLoading(false); + }); + } + + return ( +
+ {uri && ( + <> + +
+ + {uri} + + + +
+
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ + )} +
+ ); +} diff --git a/login/apps/login/src/components/translated.tsx b/login/apps/login/src/components/translated.tsx new file mode 100644 index 0000000000..807ea18e8f --- /dev/null +++ b/login/apps/login/src/components/translated.tsx @@ -0,0 +1,23 @@ +import { useTranslations } from "next-intl"; + +export function Translated({ + i18nKey, + children, + namespace, + data, + ...props +}: { + i18nKey: string; + children?: React.ReactNode; + namespace?: string; + data?: any; +} & React.HTMLAttributes) { + const t = useTranslations(namespace); + const helperKey = `${namespace ? `${namespace}.` : ""}${i18nKey}`; + + return ( + + {t(i18nKey, data)} + + ); +} diff --git a/login/apps/login/src/components/user-avatar.tsx b/login/apps/login/src/components/user-avatar.tsx new file mode 100644 index 0000000000..f2aa0bfed7 --- /dev/null +++ b/login/apps/login/src/components/user-avatar.tsx @@ -0,0 +1,59 @@ +import { Avatar } from "@/components/avatar"; +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; + +type Props = { + loginName?: string; + displayName?: string; + showDropdown: boolean; + searchParams?: Record; +}; + +export function UserAvatar({ + loginName, + displayName, + showDropdown, + searchParams, +}: Props) { + const params = new URLSearchParams({}); + + if (searchParams?.sessionId) { + params.set("sessionId", searchParams.sessionId); + } + + if (searchParams?.organization) { + params.set("organization", searchParams.organization); + } + + if (searchParams?.requestId) { + params.set("requestId", searchParams.requestId); + } + + if (searchParams?.loginName) { + params.set("loginName", searchParams.loginName); + } + + return ( +
+
+ +
+ + {loginName} + + + {showDropdown && ( + + + + )} +
+ ); +} diff --git a/login/apps/login/src/components/username-form.tsx b/login/apps/login/src/components/username-form.tsx new file mode 100644 index 0000000000..1dffade4b5 --- /dev/null +++ b/login/apps/login/src/components/username-form.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { sendLoginname } from "@/lib/server/loginname"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useRouter } from "next/navigation"; +import { ReactNode, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = { + loginName: string; +}; + +type Props = { + loginName: string | undefined; + requestId: string | undefined; + loginSettings: LoginSettings | undefined; + organization?: string; + suffix?: string; + submit: boolean; + allowRegister: boolean; + children?: ReactNode; +}; + +export function UsernameForm({ + loginName, + requestId, + organization, + suffix, + loginSettings, + submit, + allowRegister, + children, +}: Props) { + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + loginName: loginName ? loginName : "", + }, + }); + + const router = useRouter(); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function submitLoginName(values: Inputs, organization?: string) { + setLoading(true); + + const res = await sendLoginname({ + loginName: values.loginName, + organization, + requestId, + suffix, + }) + .catch(() => { + setError("An internal error occurred"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (res && "redirect" in res && res.redirect) { + return router.push(res.redirect); + } + + if (res && "error" in res && res.error) { + setError(res.error); + return; + } + + return res; + } + + useEffect(() => { + if (submit && loginName) { + // When we navigate to this page, we always want to be redirected if submit is true and the parameters are valid. + submitLoginName({ loginName }, organization); + } + }, []); + + let inputLabel = "Loginname"; + if ( + loginSettings?.disableLoginWithEmail && + loginSettings?.disableLoginWithPhone + ) { + inputLabel = "Username"; + } else if (loginSettings?.disableLoginWithEmail) { + inputLabel = "Username or phone number"; + } else if (loginSettings?.disableLoginWithPhone) { + inputLabel = "Username or email"; + } + + return ( +
+
+ + {allowRegister && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} +
+ + + +
+
+ ); +} diff --git a/login/apps/login/src/components/verify-form.tsx b/login/apps/login/src/components/verify-form.tsx new file mode 100644 index 0000000000..dac4c91314 --- /dev/null +++ b/login/apps/login/src/components/verify-form.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { Alert, AlertType } from "@/components/alert"; +import { resendVerification, sendVerification } from "@/lib/server/verify"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = { + code: string; +}; + +type Props = { + userId: string; + loginName?: string; + organization?: string; + code?: string; + isInvite: boolean; + requestId?: string; +}; + +export function VerifyForm({ + userId, + loginName, + organization, + requestId, + code, + isInvite, +}: Props) { + const router = useRouter(); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: code ?? "", + }, + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + async function resendCode() { + setError(""); + setLoading(true); + + const response = await resendVerification({ + userId, + isInvite: isInvite, + }) + .catch(() => { + setError("Could not resend email"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response.error); + return; + } + + return response; + } + + const fcn = useCallback( + async function submitCodeAndContinue( + value: Inputs, + ): Promise { + setLoading(true); + + const response = await sendVerification({ + code: value.code, + userId, + isInvite: isInvite, + loginName: loginName, + organization: organization, + requestId: requestId, + }) + .catch(() => { + setError("Could not verify user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response?.redirect) { + return router.push(response?.redirect); + } + }, + [isInvite, userId], + ); + + useEffect(() => { + if (code) { + fcn({ code }); + } + }, [code, fcn]); + + return ( + <> +
+ +
+ + + + +
+
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ + ); +} diff --git a/login/apps/login/src/components/zitadel-logo-dark.tsx b/login/apps/login/src/components/zitadel-logo-dark.tsx new file mode 100644 index 0000000000..0df6ae2004 --- /dev/null +++ b/login/apps/login/src/components/zitadel-logo-dark.tsx @@ -0,0 +1,210 @@ +import { FC } from "react"; + +export const ZitadelLogoDark: FC = (props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/login/apps/login/src/components/zitadel-logo-light.tsx b/login/apps/login/src/components/zitadel-logo-light.tsx new file mode 100644 index 0000000000..51529aa821 --- /dev/null +++ b/login/apps/login/src/components/zitadel-logo-light.tsx @@ -0,0 +1,210 @@ +import { FC } from "react"; + +export const ZitadelLogoLight: FC = (props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/login/apps/login/src/components/zitadel-logo.tsx b/login/apps/login/src/components/zitadel-logo.tsx new file mode 100644 index 0000000000..105665fbba --- /dev/null +++ b/login/apps/login/src/components/zitadel-logo.tsx @@ -0,0 +1,32 @@ +import Image from "next/image"; +type Props = { + height?: number; + width?: number; +}; + +export function ZitadelLogo({ height = 40, width = 147.5 }: Props) { + return ( + <> +
+ {/* */} + + zitadel logo +
+
+ zitadel logo +
+ + ); +} diff --git a/login/apps/login/src/helpers/base64.ts b/login/apps/login/src/helpers/base64.ts new file mode 100644 index 0000000000..967cdc8d17 --- /dev/null +++ b/login/apps/login/src/helpers/base64.ts @@ -0,0 +1,63 @@ +export function coerceToBase64Url(thing: any, name: string) { + // Array or ArrayBuffer to Uint8Array + if (Array.isArray(thing)) { + thing = Uint8Array.from(thing); + } + + if (thing instanceof ArrayBuffer) { + thing = new Uint8Array(thing); + } + + // Uint8Array to base64 + if (thing instanceof Uint8Array) { + var str = ""; + var len = thing.byteLength; + + for (var i = 0; i < len; i++) { + str += String.fromCharCode(thing[i]); + } + thing = window.btoa(str); + } + + if (typeof thing !== "string") { + throw new Error("could not coerce '" + name + "' to string"); + } + + // base64 to base64url + // NOTE: "=" at the end of challenge is optional, strip it off here + thing = thing.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); + + return thing; +} + +export function coerceToArrayBuffer(thing: any, name: string) { + if (typeof thing === "string") { + // base64url to base64 + thing = thing.replace(/-/g, "+").replace(/_/g, "/"); + + // base64 to Uint8Array + var str = window.atob(thing); + var bytes = new Uint8Array(str.length); + for (var i = 0; i < str.length; i++) { + bytes[i] = str.charCodeAt(i); + } + thing = bytes; + } + + // Array to Uint8Array + if (Array.isArray(thing)) { + thing = new Uint8Array(thing); + } + + // Uint8Array to ArrayBuffer + if (thing instanceof Uint8Array) { + thing = thing.buffer; + } + + // error if none of the above worked + if (!(thing instanceof ArrayBuffer)) { + throw new TypeError("could not coerce '" + name + "' to ArrayBuffer"); + } + + return thing; +} diff --git a/login/apps/login/src/helpers/colors.ts b/login/apps/login/src/helpers/colors.ts new file mode 100644 index 0000000000..bdb07cecfd --- /dev/null +++ b/login/apps/login/src/helpers/colors.ts @@ -0,0 +1,439 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import tinycolor from "tinycolor2"; + +export interface Color { + name: string; + hex: string; + rgb: string; + contrastColor: string; +} + +export type MapName = "background" | "primary" | "warn" | "text" | "link"; + +export type ColorName = + | "50" + | "100" + | "200" + | "300" + | "400" + | "500" + | "600" + | "700" + | "800" + | "C900" + | "A100" + | "A200" + | "A400" + | "A700"; + +export type ColorMap = { + [key in MapName]: Color[]; +}; + +export const DARK_PRIMARY = "#2073c4"; +export const PRIMARY = "#5469d4"; + +export const DARK_WARN = "#ff3b5b"; +export const WARN = "#cd3d56"; + +export const DARK_BACKGROUND = "#111827"; +export const BACKGROUND = "#fafafa"; + +export const DARK_TEXT = "#ffffff"; +export const TEXT = "#000000"; + +export type LabelPolicyColors = { + backgroundColor: string; + backgroundColorDark: string; + fontColor: string; + fontColorDark: string; + warnColor: string; + warnColorDark: string; + primaryColor: string; + primaryColorDark: string; +}; + +type BrandingColors = { + lightTheme: { + backgroundColor: string; + fontColor: string; + primaryColor: string; + warnColor: string; + }; + darkTheme: { + backgroundColor: string; + fontColor: string; + primaryColor: string; + warnColor: string; + }; +}; + +export function setTheme(document: any, policy?: BrandingSettings) { + const lP: BrandingColors = { + lightTheme: { + backgroundColor: policy?.lightTheme?.backgroundColor || BACKGROUND, + fontColor: policy?.lightTheme?.fontColor || TEXT, + primaryColor: policy?.lightTheme?.primaryColor || PRIMARY, + warnColor: policy?.lightTheme?.warnColor || WARN, + }, + darkTheme: { + backgroundColor: policy?.darkTheme?.backgroundColor || DARK_BACKGROUND, + fontColor: policy?.darkTheme?.fontColor || DARK_TEXT, + primaryColor: policy?.darkTheme?.primaryColor || DARK_PRIMARY, + warnColor: policy?.darkTheme?.warnColor || DARK_WARN, + }, + }; + + const dark = computeMap(lP, true); + const light = computeMap(lP, false); + + setColorShades(dark.background, "background", "dark", document); + setColorShades(light.background, "background", "light", document); + + setColorShades(dark.primary, "primary", "dark", document); + setColorShades(light.primary, "primary", "light", document); + + setColorShades(dark.warn, "warn", "dark", document); + setColorShades(light.warn, "warn", "light", document); + + setColorAlpha(dark.text, "text", "dark", document); + setColorAlpha(light.text, "text", "light", document); + + setColorAlpha(dark.link, "link", "dark", document); + setColorAlpha(light.link, "link", "light", document); +} + +function setColorShades( + map: Color[], + type: string, + theme: string, + document: any, +) { + map.forEach((color) => { + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-${color.name}`, + color.hex, + ); + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-contrast-${color.name}`, + color.contrastColor, + ); + }); +} + +function setColorAlpha( + map: Color[], + type: string, + theme: string, + document: any, +) { + map.forEach((color) => { + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-${color.name}`, + color.hex, + ); + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-contrast-${color.name}`, + color.contrastColor, + ); + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-secondary-${color.name}`, + `${color.hex}c7`, + ); + }); +} + +function computeColors(hex: string): Color[] { + return [ + getColorObject(tinycolor(hex).lighten(52), "50"), + getColorObject(tinycolor(hex).lighten(37), "100"), + getColorObject(tinycolor(hex).lighten(26), "200"), + getColorObject(tinycolor(hex).lighten(12), "300"), + getColorObject(tinycolor(hex).lighten(6), "400"), + getColorObject(tinycolor(hex), "500"), + getColorObject(tinycolor(hex).darken(6), "600"), + getColorObject(tinycolor(hex).darken(12), "700"), + getColorObject(tinycolor(hex).darken(18), "800"), + getColorObject(tinycolor(hex).darken(24), "900"), + getColorObject(tinycolor(hex).lighten(50).saturate(30), "A100"), + getColorObject(tinycolor(hex).lighten(30).saturate(30), "A200"), + getColorObject(tinycolor(hex).lighten(10).saturate(15), "A400"), + getColorObject(tinycolor(hex).lighten(5).saturate(5), "A700"), + ]; +} + +function getColorObject(value: any, name: string): Color { + const c = tinycolor(value); + return { + name: name, + hex: c.toHexString(), + rgb: c.toRgbString(), + contrastColor: getContrast(c.toHexString()), + } as Color; +} + +function getContrast(color: string): string { + const onBlack = tinycolor.readability("#000", color); + const onWhite = tinycolor.readability("#fff", color); + if (onBlack > onWhite) { + return "hsla(0, 0%, 0%, 0.87)"; + } else { + return "#ffffff"; + } +} + +export function computeMap(branding: BrandingColors, dark: boolean): ColorMap { + return { + background: computeColors( + dark + ? branding.darkTheme.backgroundColor + : branding.lightTheme.backgroundColor, + ), + primary: computeColors( + dark ? branding.darkTheme.primaryColor : branding.lightTheme.primaryColor, + ), + warn: computeColors( + dark ? branding.darkTheme.warnColor : branding.lightTheme.warnColor, + ), + text: computeColors( + dark ? branding.darkTheme.fontColor : branding.lightTheme.fontColor, + ), + link: computeColors( + dark ? branding.darkTheme.fontColor : branding.lightTheme.fontColor, + ), + }; +} + +export interface ColorShade { + 200: string; + 300: string; + 500: string; + 600: string; + 700: string; + 900: string; +} + +export const COLORS = [ + { + 500: "#ef4444", + 200: "#fecaca", + 300: "#fca5a5", + 600: "#dc2626", + 700: "#b91c1c", + 900: "#7f1d1d", + }, + { + 500: "#f97316", + 200: "#fed7aa", + 300: "#fdba74", + 600: "#ea580c", + 700: "#c2410c", + 900: "#7c2d12", + }, + { + 500: "#f59e0b", + 200: "#fde68a", + 300: "#fcd34d", + 600: "#d97706", + 700: "#b45309", + 900: "#78350f", + }, + { + 500: "#eab308", + 200: "#fef08a", + 300: "#fde047", + 600: "#ca8a04", + 700: "#a16207", + 900: "#713f12", + }, + { + 500: "#84cc16", + 200: "#d9f99d", + 300: "#bef264", + 600: "#65a30d", + 700: "#4d7c0f", + 900: "#365314", + }, + { + 500: "#22c55e", + 200: "#bbf7d0", + 300: "#86efac", + 600: "#16a34a", + 700: "#15803d", + 900: "#14532d", + }, + { + 500: "#10b981", + 200: "#a7f3d0", + 300: "#6ee7b7", + 600: "#059669", + 700: "#047857", + 900: "#064e3b", + }, + { + 500: "#14b8a6", + 200: "#99f6e4", + 300: "#5eead4", + 600: "#0d9488", + 700: "#0f766e", + 900: "#134e4a", + }, + { + 500: "#06b6d4", + 200: "#a5f3fc", + 300: "#67e8f9", + 600: "#0891b2", + 700: "#0e7490", + 900: "#164e63", + }, + { + 500: "#0ea5e9", + 200: "#bae6fd", + 300: "#7dd3fc", + 600: "#0284c7", + 700: "#0369a1", + 900: "#0c4a6e", + }, + { + 500: "#3b82f6", + 200: "#bfdbfe", + 300: "#93c5fd", + 600: "#2563eb", + 700: "#1d4ed8", + 900: "#1e3a8a", + }, + { + 500: "#6366f1", + 200: "#c7d2fe", + 300: "#a5b4fc", + 600: "#4f46e5", + 700: "#4338ca", + 900: "#312e81", + }, + { + 500: "#8b5cf6", + 200: "#ddd6fe", + 300: "#c4b5fd", + 600: "#7c3aed", + 700: "#6d28d9", + 900: "#4c1d95", + }, + { + 500: "#a855f7", + 200: "#e9d5ff", + 300: "#d8b4fe", + 600: "#9333ea", + 700: "#7e22ce", + 900: "#581c87", + }, + { + 500: "#d946ef", + 200: "#f5d0fe", + 300: "#f0abfc", + 600: "#c026d3", + 700: "#a21caf", + 900: "#701a75", + }, + { + 500: "#ec4899", + 200: "#fbcfe8", + 300: "#f9a8d4", + 600: "#db2777", + 700: "#be185d", + 900: "#831843", + }, + { + 500: "#f43f5e", + 200: "#fecdd3", + 300: "#fda4af", + 600: "#e11d48", + 700: "#be123c", + 900: "#881337", + }, +]; + +export function getColorHash(value: string): ColorShade { + let hash = 0; + + if (value.length === 0) { + return COLORS[hash]; + } + + hash = hashCode(value); + return COLORS[hash % COLORS.length]; +} + +export function hashCode(str: string, seed = 0): number { + let h1 = 0xdeadbeef ^ seed, + h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = + Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ + Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = + Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ + Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +} + +export function getMembershipColor(role: string): ColorShade { + const hash = hashCode(role); + let color = COLORS[hash % COLORS.length]; + + switch (role) { + case "IAM_OWNER": + color = COLORS[0]; + break; + case "IAM_OWNER_VIEWER": + color = COLORS[14]; + break; + case "IAM_ORG_MANAGER": + color = COLORS[11]; + break; + case "IAM_USER_MANAGER": + color = COLORS[8]; + break; + + case "ORG_OWNER": + color = COLORS[16]; + break; + case "ORG_USER_MANAGER": + color = COLORS[8]; + break; + case "ORG_OWNER_VIEWER": + color = COLORS[14]; + break; + case "ORG_USER_PERMISSION_EDITOR": + color = COLORS[7]; + break; + case "ORG_PROJECT_PERMISSION_EDITOR": + color = COLORS[11]; + break; + case "ORG_PROJECT_CREATOR": + color = COLORS[12]; + break; + + case "PROJECT_OWNER": + color = COLORS[9]; + break; + case "PROJECT_OWNER_VIEWER": + color = COLORS[10]; + break; + case "PROJECT_OWNER_GLOBAL": + color = COLORS[11]; + break; + case "PROJECT_OWNER_VIEWER_GLOBAL": + color = COLORS[12]; + break; + + default: + color = COLORS[hash % COLORS.length]; + break; + } + + return color; +} diff --git a/login/apps/login/src/helpers/validators.ts b/login/apps/login/src/helpers/validators.ts new file mode 100644 index 0000000000..6a61d13ece --- /dev/null +++ b/login/apps/login/src/helpers/validators.ts @@ -0,0 +1,19 @@ +export function symbolValidator(value: string): boolean { + const REGEXP = /[^a-zA-Z0-9]/gi; + return REGEXP.test(value); +} + +export function numberValidator(value: string): boolean { + const REGEXP = /[0-9]/g; + return REGEXP.test(value); +} + +export function upperCaseValidator(value: string): boolean { + const REGEXP = /[A-Z]/g; + return REGEXP.test(value); +} + +export function lowerCaseValidator(value: string): boolean { + const REGEXP = /[a-z]/g; + return REGEXP.test(value); +} diff --git a/login/apps/login/src/i18n/request.ts b/login/apps/login/src/i18n/request.ts new file mode 100644 index 0000000000..15cfe01548 --- /dev/null +++ b/login/apps/login/src/i18n/request.ts @@ -0,0 +1,59 @@ +import { LANGS, LANGUAGE_COOKIE_NAME, LANGUAGE_HEADER_NAME } from "@/lib/i18n"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getHostedLoginTranslation } from "@/lib/zitadel"; +import { JsonObject } from "@zitadel/client"; +import deepmerge from "deepmerge"; +import { getRequestConfig } from "next-intl/server"; +import { cookies, headers } from "next/headers"; + +export default getRequestConfig(async () => { + const fallback = "en"; + const cookiesList = await cookies(); + + let locale: string = fallback; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); + if (languageHeader) { + const headerLocale = languageHeader.split(",")[0].split("-")[0]; // Extract the language code + if (LANGS.map((l) => l.code).includes(headerLocale)) { + locale = headerLocale; + } + } + + const languageCookie = cookiesList?.get(LANGUAGE_COOKIE_NAME); + if (languageCookie && languageCookie.value) { + if (LANGS.map((l) => l.code).includes(languageCookie.value)) { + locale = languageCookie.value; + } + } + + const i18nOrganization = _headers.get("x-zitadel-i18n-organization") || ""; // You may need to set this header in middleware + + let translations: JsonObject | {} = {}; + try { + const i18nJSON = await getHostedLoginTranslation({ + serviceUrl, + locale, + organization: i18nOrganization, + }); + + if (i18nJSON) { + translations = i18nJSON; + } + } catch (error) { + console.warn("Error fetching custom translations:", error); + } + + const customMessages = translations; + const localeMessages = (await import(`../../locales/${locale}.json`)).default; + const fallbackMessages = (await import(`../../locales/${fallback}.json`)) + .default; + + return { + locale, + messages: deepmerge.all([fallbackMessages, localeMessages, customMessages]), + }; +}); diff --git a/login/apps/login/src/lib/api.ts b/login/apps/login/src/lib/api.ts new file mode 100644 index 0000000000..7324007307 --- /dev/null +++ b/login/apps/login/src/lib/api.ts @@ -0,0 +1,17 @@ +import { newSystemToken } from "@zitadel/client/node"; + +export async function systemAPIToken() { + const token = { + audience: process.env.AUDIENCE, + userID: process.env.SYSTEM_USER_ID, + token: Buffer.from(process.env.SYSTEM_USER_PRIVATE_KEY, "base64").toString( + "utf-8", + ), + }; + + return newSystemToken({ + audience: token.audience, + subject: token.userID, + key: token.token, + }); +} diff --git a/login/apps/login/src/lib/client.ts b/login/apps/login/src/lib/client.ts new file mode 100644 index 0000000000..a59af90b77 --- /dev/null +++ b/login/apps/login/src/lib/client.ts @@ -0,0 +1,80 @@ +type FinishFlowCommand = + | { + sessionId: string; + requestId: string; + } + | { loginName: string }; + +function goToSignedInPage( + props: + | { sessionId: string; organization?: string; requestId?: string } + | { organization?: string; loginName: string; requestId?: string }, +) { + const params = new URLSearchParams({}); + + if ("loginName" in props && props.loginName) { + params.append("loginName", props.loginName); + } + + if ("sessionId" in props && props.sessionId) { + params.append("sessionId", props.sessionId); + } + + if (props.organization) { + params.append("organization", props.organization); + } + + // required to show conditional UI for device flow + if (props.requestId) { + params.append("requestId", props.requestId); + } + + return `/signedin?` + params; +} + +/** + * for client: redirects user back to an OIDC or SAML application or to a success page when using requestId, check if a default redirect and redirect to it, or just redirect to a success page with the loginName + * @param command + * @returns + */ +export async function getNextUrl( + command: FinishFlowCommand & { organization?: string }, + defaultRedirectUri?: string, +): Promise { + // finish Device Authorization Flow + if ( + "requestId" in command && + command.requestId.startsWith("device_") && + ("loginName" in command || "sessionId" in command) + ) { + return goToSignedInPage({ + ...command, + organization: command.organization, + }); + } + + // finish SAML or OIDC flow + if ( + "sessionId" in command && + "requestId" in command && + (command.requestId.startsWith("saml_") || + command.requestId.startsWith("oidc_")) + ) { + const params = new URLSearchParams({ + sessionId: command.sessionId, + requestId: command.requestId, + }); + + if (command.organization) { + params.append("organization", command.organization); + } + + return `/login?` + params; + } + + if (defaultRedirectUri) { + return defaultRedirectUri; + } + + return goToSignedInPage(command); +} diff --git a/login/apps/login/src/lib/cookies.ts b/login/apps/login/src/lib/cookies.ts new file mode 100644 index 0000000000..7de87a98e7 --- /dev/null +++ b/login/apps/login/src/lib/cookies.ts @@ -0,0 +1,341 @@ +"use server"; + +import { timestampDate, timestampFromMs } from "@zitadel/client"; +import { cookies } from "next/headers"; +import { LANGUAGE_COOKIE_NAME } from "./i18n"; + +// TODO: improve this to handle overflow +const MAX_COOKIE_SIZE = 2048; + +export type Cookie = { + id: string; + token: string; + loginName: string; + organization?: string; + creationTs: string; + expirationTs: string; + changeTs: string; + requestId?: string; // if its linked to an OIDC flow +}; + +type SessionCookie = Cookie & T; + +async function setSessionHttpOnlyCookie( + sessions: SessionCookie[], + sameSite: boolean | "lax" | "strict" | "none" = true, +) { + const cookiesList = await cookies(); + + return cookiesList.set({ + name: "sessions", + value: JSON.stringify(sessions), + httpOnly: true, + path: "/", + sameSite: process.env.NODE_ENV === "production" ? sameSite : "lax", + secure: process.env.NODE_ENV === "production", + }); +} + +export async function setLanguageCookie(language: string) { + const cookiesList = await cookies(); + + await cookiesList.set({ + name: LANGUAGE_COOKIE_NAME, + value: language, + httpOnly: true, + path: "/", + }); +} + +export async function addSessionToCookie({ + session, + cleanup, + sameSite, +}: { + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + let currentSessions: SessionCookie[] = stringifiedCookie?.value + ? JSON.parse(stringifiedCookie?.value) + : []; + + const index = currentSessions.findIndex( + (s) => s.loginName === session.loginName, + ); + + if (index > -1) { + currentSessions[index] = session; + } else { + const temp = [...currentSessions, session]; + + if (JSON.stringify(temp).length >= MAX_COOKIE_SIZE) { + console.log("WARNING COOKIE OVERFLOW"); + // TODO: improve cookie handling + // this replaces the first session (oldest) with the new one + currentSessions = [session].concat(currentSessions.slice(1)); + } else { + currentSessions = [session].concat(currentSessions); + } + } + + if (cleanup) { + const now = new Date(); + const filteredSessions = currentSessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); + } else { + return setSessionHttpOnlyCookie(currentSessions, sameSite); + } +} + +export async function updateSessionCookie({ + id, + session, + cleanup, + sameSite, +}: { + id: string; + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + const sessions: SessionCookie[] = stringifiedCookie?.value + ? JSON.parse(stringifiedCookie?.value) + : [session]; + + const foundIndex = sessions.findIndex((session) => session.id === id); + + if (foundIndex > -1) { + sessions[foundIndex] = session; + if (cleanup) { + const now = new Date(); + const filteredSessions = sessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); + } else { + return setSessionHttpOnlyCookie(sessions, sameSite); + } + } else { + throw "updateSessionCookie: session id now found"; + } +} + +export async function removeSessionFromCookie({ + session, + cleanup, + sameSite, +}: { + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}) { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + const sessions: SessionCookie[] = stringifiedCookie?.value + ? JSON.parse(stringifiedCookie?.value) + : [session]; + + const reducedSessions = sessions.filter((s) => s.id !== session.id); + if (cleanup) { + const now = new Date(); + const filteredSessions = reducedSessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); + } else { + return setSessionHttpOnlyCookie(reducedSessions, sameSite); + } +} + +export async function getMostRecentSessionCookie(): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + const latest = sessions.reduce((prev, current) => { + return prev.changeTs > current.changeTs ? prev : current; + }); + + return latest; + } else { + return Promise.reject("no session cookie found"); + } +} + +export async function getSessionCookieById({ + sessionId, + organization, +}: { + sessionId: string; + organization?: string; +}): Promise> { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + const found = sessions.find((s) => + organization + ? s.organization === organization && s.id === sessionId + : s.id === sessionId, + ); + if (found) { + return found; + } else { + return Promise.reject(); + } + } else { + return Promise.reject(); + } +} + +export async function getSessionCookieByLoginName({ + loginName, + organization, +}: { + loginName?: string; + organization?: string; +}): Promise> { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + const found = sessions.find((s) => + organization + ? s.organization === organization && s.loginName === loginName + : s.loginName === loginName, + ); + if (found) { + return found; + } else { + return Promise.reject("no cookie found with loginName: " + loginName); + } + } else { + return Promise.reject("no session cookie found"); + } +} + +/** + * + * @param cleanup when true, removes all expired sessions, default true + * @returns Session Cookies + */ +export async function getAllSessionCookieIds( + cleanup: boolean = false, +): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + if (cleanup) { + const now = new Date(); + return sessions + .filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ) + .map((session) => session.id); + } else { + return sessions.map((session) => session.id); + } + } else { + return []; + } +} + +/** + * + * @param cleanup when true, removes all expired sessions, default true + * @returns Session Cookies + */ +export async function getAllSessions( + cleanup: boolean = false, +): Promise[]> { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + if (cleanup) { + const now = new Date(); + return sessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + } else { + return sessions; + } + } else { + return []; + } +} + +/** + * Returns most recent session filtered by optinal loginName + * @param loginName optional loginName to filter cookies, if non provided, returns most recent session + * @param organization optional organization to filter cookies + * @returns most recent session + */ +export async function getMostRecentCookieWithLoginname({ + loginName, + organization, +}: { + loginName?: string; + organization?: string; +}): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + let filtered = sessions.filter((cookie) => { + return !!loginName ? cookie.loginName === loginName : true; + }); + + if (organization) { + filtered = filtered.filter((cookie) => { + return cookie.organization === organization; + }); + } + + const latest = + filtered && filtered.length + ? filtered.reduce((prev, current) => { + return prev.changeTs > current.changeTs ? prev : current; + }) + : undefined; + + if (latest) { + return latest; + } else { + return Promise.reject("Could not get the context or retrieve a session"); + } + } else { + return Promise.reject("Could not read session cookie"); + } +} diff --git a/login/apps/login/src/lib/demos.ts b/login/apps/login/src/lib/demos.ts new file mode 100644 index 0000000000..38912e50e5 --- /dev/null +++ b/login/apps/login/src/lib/demos.ts @@ -0,0 +1,38 @@ +export type Item = { + name: string; + slug: string; + description?: string; +}; + +export const demos: { name: string; items: Item[] }[] = [ + { + name: "Login", + items: [ + { + name: "Loginname", + slug: "loginname", + description: "Start the loginflow with loginname", + }, + { + name: "Accounts", + slug: "accounts", + description: "List active and inactive sessions", + }, + ], + }, + { + name: "Register", + items: [ + { + name: "Register", + slug: "register", + description: "Add a user with password or passkey", + }, + { + name: "IDP Register", + slug: "idp", + description: "Add a user from an external identity provider", + }, + ], + }, +]; diff --git a/login/apps/login/src/lib/fingerprint.ts b/login/apps/login/src/lib/fingerprint.ts new file mode 100644 index 0000000000..55b59dadc8 --- /dev/null +++ b/login/apps/login/src/lib/fingerprint.ts @@ -0,0 +1,66 @@ +import { create } from "@zitadel/client"; +import { + UserAgent, + UserAgentSchema, +} from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { cookies, headers } from "next/headers"; +import { userAgent } from "next/server"; +import { v4 as uuidv4 } from "uuid"; + +export async function getFingerprintId() { + return uuidv4(); +} + +export async function setFingerprintIdCookie(fingerprintId: string) { + const cookiesList = await cookies(); + + return cookiesList.set({ + name: "fingerprintId", + value: fingerprintId, + httpOnly: true, + path: "/", + maxAge: 31536000, // 1 year + }); +} + +export async function getFingerprintIdCookie() { + const cookiesList = await cookies(); + return cookiesList.get("fingerprintId"); +} + +export async function getOrSetFingerprintId(): Promise { + const cookie = await getFingerprintIdCookie(); + if (cookie) { + return cookie.value; + } + + const fingerprintId = await getFingerprintId(); + await setFingerprintIdCookie(fingerprintId); + return fingerprintId; +} + +export async function getUserAgent(): Promise { + const _headers = await headers(); + + const fingerprintId = await getOrSetFingerprintId(); + + const { device, engine, os, browser } = userAgent({ headers: _headers }); + + const userAgentHeader = _headers.get("user-agent"); + + const userAgentHeaderValues = userAgentHeader?.split(","); + + const deviceDescription = `${device?.type ? `${device.type},` : ""} ${device?.vendor ? `${device.vendor},` : ""} ${device.model ? `${device.model},` : ""} `; + const osDescription = `${os?.name ? `${os.name},` : ""} ${os?.version ? `${os.version},` : ""} `; + const engineDescription = `${engine?.name ? `${engine.name},` : ""} ${engine?.version ? `${engine.version},` : ""} `; + const browserDescription = `${browser?.name ? `${browser.name},` : ""} ${browser.version ? `${browser.version},` : ""} `; + + const userAgentData: UserAgent = create(UserAgentSchema, { + ip: _headers.get("x-forwarded-for") ?? _headers.get("remoteAddress") ?? "", + header: { "user-agent": { values: userAgentHeaderValues } }, + description: `${browserDescription}, ${deviceDescription}, ${engineDescription}, ${osDescription}`, + fingerprintId: fingerprintId, + }); + + return userAgentData; +} diff --git a/login/apps/login/src/lib/hooks.ts b/login/apps/login/src/lib/hooks.ts new file mode 100644 index 0000000000..2d43fe5adc --- /dev/null +++ b/login/apps/login/src/lib/hooks.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +// Custom hook to read auth record and user profile doc +export function useUserData() { + const [clientData, setClientData] = useState(null); + + useEffect(() => { + let unsubscribe; + + return unsubscribe; + }, [clientData]); + + return { clientData }; +} diff --git a/login/apps/login/src/lib/i18n.ts b/login/apps/login/src/lib/i18n.ts new file mode 100644 index 0000000000..5a101dcc8f --- /dev/null +++ b/login/apps/login/src/lib/i18n.ts @@ -0,0 +1,38 @@ +export interface Lang { + name: string; + code: string; +} + +export const LANGS: Lang[] = [ + { + name: "English", + code: "en", + }, + { + name: "Deutsch", + code: "de", + }, + { + name: "Italiano", + code: "it", + }, + { + name: "Español", + code: "es", + }, + { + name: "Polski", + code: "pl", + }, + { + name: "简体中文", + code: "zh", + }, + { + name: "Русский", + code: "ru", + }, +]; + +export const LANGUAGE_COOKIE_NAME = "NEXT_LOCALE"; +export const LANGUAGE_HEADER_NAME = "accept-language"; diff --git a/login/apps/login/src/lib/idp.ts b/login/apps/login/src/lib/idp.ts new file mode 100644 index 0000000000..d355f9ab56 --- /dev/null +++ b/login/apps/login/src/lib/idp.ts @@ -0,0 +1,77 @@ +import { IDPType } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; +import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; + +// This maps the IdentityProviderType to a slug which is used in the /success and /failure routes +export function idpTypeToSlug(idpType: IdentityProviderType) { + switch (idpType) { + case IdentityProviderType.GITHUB: + return "github"; + case IdentityProviderType.GITHUB_ES: + return "github_es"; + case IdentityProviderType.GITLAB: + return "gitlab"; + case IdentityProviderType.GITLAB_SELF_HOSTED: + return "gitlab_es"; + case IdentityProviderType.APPLE: + return "apple"; + case IdentityProviderType.GOOGLE: + return "google"; + case IdentityProviderType.AZURE_AD: + return "azure"; + case IdentityProviderType.SAML: + return "saml"; + case IdentityProviderType.OAUTH: + return "oauth"; + case IdentityProviderType.OIDC: + return "oidc"; + case IdentityProviderType.LDAP: + return "ldap"; + case IdentityProviderType.JWT: + return "jwt"; + default: + throw new Error("Unknown identity provider type"); + } +} + +// TODO: this is ugly but needed atm as the getIDPByID returns a IDPType and not a IdentityProviderType +export function idpTypeToIdentityProviderType( + idpType: IDPType, +): IdentityProviderType { + switch (idpType) { + case IDPType.IDP_TYPE_GITHUB: + return IdentityProviderType.GITHUB; + + case IDPType.IDP_TYPE_GITHUB_ES: + return IdentityProviderType.GITHUB_ES; + + case IDPType.IDP_TYPE_GITLAB: + return IdentityProviderType.GITLAB; + + case IDPType.IDP_TYPE_GITLAB_SELF_HOSTED: + return IdentityProviderType.GITLAB_SELF_HOSTED; + + case IDPType.IDP_TYPE_APPLE: + return IdentityProviderType.APPLE; + + case IDPType.IDP_TYPE_GOOGLE: + return IdentityProviderType.GOOGLE; + + case IDPType.IDP_TYPE_AZURE_AD: + return IdentityProviderType.AZURE_AD; + + case IDPType.IDP_TYPE_SAML: + return IdentityProviderType.SAML; + + case IDPType.IDP_TYPE_OAUTH: + return IdentityProviderType.OAUTH; + + case IDPType.IDP_TYPE_OIDC: + return IdentityProviderType.OIDC; + + case IDPType.IDP_TYPE_JWT: + return IdentityProviderType.JWT; + + default: + throw new Error("Unknown identity provider type"); + } +} diff --git a/login/apps/login/src/lib/oidc.ts b/login/apps/login/src/lib/oidc.ts new file mode 100644 index 0000000000..b692300dea --- /dev/null +++ b/login/apps/login/src/lib/oidc.ts @@ -0,0 +1,132 @@ +import { Cookie } from "@/lib/cookies"; +import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; +import { createCallback, getLoginSettings } from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { + CreateCallbackRequestSchema, + SessionSchema, +} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { NextRequest, NextResponse } from "next/server"; +import { constructUrl } from "./service-url"; +import { isSessionValid } from "./session"; + +type LoginWithOIDCAndSession = { + serviceUrl: string; + authRequest: string; + sessionId: string; + sessions: Session[]; + sessionCookies: Cookie[]; + request: NextRequest; +}; +export async function loginWithOIDCAndSession({ + serviceUrl, + authRequest, + sessionId, + sessions, + sessionCookies, + request, +}: LoginWithOIDCAndSession) { + console.log( + `Login with session: ${sessionId} and authRequest: ${authRequest}`, + ); + + const selectedSession = sessions.find((s) => s.id === sessionId); + + if (selectedSession && selectedSession.id) { + console.log(`Found session ${selectedSession.id}`); + + const isValid = await isSessionValid({ + serviceUrl, + session: selectedSession, + }); + + console.log("Session is valid:", isValid); + + if (!isValid && selectedSession.factors?.user) { + // if the session is not valid anymore, we need to redirect the user to re-authenticate / + // TODO: handle IDP intent direcly if available + const command: SendLoginnameCommand = { + loginName: selectedSession.factors.user?.loginName, + organization: selectedSession.factors?.user?.organizationId, + requestId: `oidc_${authRequest}`, + }; + + const res = await sendLoginname(command); + + if (res && "redirect" in res && res?.redirect) { + const absoluteUrl = constructUrl(request, res.redirect); + return NextResponse.redirect(absoluteUrl.toString()); + } + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession?.id, + ); + + if (cookie && cookie.id && cookie.token) { + const session = { + sessionId: cookie?.id, + sessionToken: cookie?.token, + }; + + // works not with _rsc request + try { + const { callbackUrl } = await createCallback({ + serviceUrl, + req: create(CreateCallbackRequestSchema, { + authRequestId: authRequest, + callbackKind: { + case: "session", + value: create(SessionSchema, session), + }, + }), + }); + if (callbackUrl) { + return NextResponse.redirect(callbackUrl); + } else { + return NextResponse.json( + { error: "An error occurred!" }, + { status: 500 }, + ); + } + } catch (error: unknown) { + // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) + console.error(error); + if ( + error && + typeof error === "object" && + "code" in error && + error?.code === 9 + ) { + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: selectedSession.factors?.user?.organizationId, + }); + + if (loginSettings?.defaultRedirectUri) { + return NextResponse.redirect(loginSettings.defaultRedirectUri); + } + + const signedinUrl = constructUrl(request, "/signedin"); + + if (selectedSession.factors?.user?.loginName) { + signedinUrl.searchParams.set( + "loginName", + selectedSession.factors?.user?.loginName, + ); + } + if (selectedSession.factors?.user?.organizationId) { + signedinUrl.searchParams.set( + "organization", + selectedSession.factors?.user?.organizationId, + ); + } + return NextResponse.redirect(signedinUrl); + } else { + return NextResponse.json({ error }, { status: 500 }); + } + } + } + } +} diff --git a/login/apps/login/src/lib/saml.ts b/login/apps/login/src/lib/saml.ts new file mode 100644 index 0000000000..e1b5f4c080 --- /dev/null +++ b/login/apps/login/src/lib/saml.ts @@ -0,0 +1,163 @@ +import { Cookie } from "@/lib/cookies"; +import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; +import { createResponse, getLoginSettings } from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { v4 as uuidv4 } from "uuid"; +import { constructUrl } from "./service-url"; +import { isSessionValid } from "./session"; + +type LoginWithSAMLAndSession = { + serviceUrl: string; + samlRequest: string; + sessionId: string; + sessions: Session[]; + sessionCookies: Cookie[]; + request: NextRequest; +}; + +export async function getSAMLFormUID() { + return uuidv4(); +} + +export async function setSAMLFormCookie(value: string): Promise { + const cookiesList = await cookies(); + + const uid = await getSAMLFormUID(); + + await cookiesList.set({ + name: uid, + value: value, + httpOnly: true, + path: "/", + maxAge: 5 * 60, // 5 minutes + }); + + return uid; +} + +export async function getSAMLFormCookie(uid: string): Promise { + const cookiesList = await cookies(); + + const cookie = cookiesList.get(uid); + if (!cookie || !cookie.value) { + return null; + } + + return cookie.value; +} + +export async function loginWithSAMLAndSession({ + serviceUrl, + samlRequest, + sessionId, + sessions, + sessionCookies, + request, +}: LoginWithSAMLAndSession) { + console.log( + `Login with session: ${sessionId} and samlRequest: ${samlRequest}`, + ); + + const selectedSession = sessions.find((s) => s.id === sessionId); + + if (selectedSession && selectedSession.id) { + console.log(`Found session ${selectedSession.id}`); + + const isValid = await isSessionValid({ + serviceUrl, + session: selectedSession, + }); + + console.log("Session is valid:", isValid); + + if (!isValid && selectedSession.factors?.user) { + // if the session is not valid anymore, we need to redirect the user to re-authenticate / + // TODO: handle IDP intent direcly if available + const command: SendLoginnameCommand = { + loginName: selectedSession.factors.user?.loginName, + organization: selectedSession.factors?.user?.organizationId, + requestId: `saml_${samlRequest}`, + }; + + const res = await sendLoginname(command); + + if (res && "redirect" in res && res?.redirect) { + const absoluteUrl = constructUrl(request, res.redirect); + return NextResponse.redirect(absoluteUrl.toString()); + } + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession?.id, + ); + + if (cookie && cookie.id && cookie.token) { + const session = { + sessionId: cookie?.id, + sessionToken: cookie?.token, + }; + + // works not with _rsc request + try { + const { url } = await createResponse({ + serviceUrl, + req: create(CreateResponseRequestSchema, { + samlRequestId: samlRequest, + responseKind: { + case: "session", + value: session, + }, + }), + }); + if (url) { + return NextResponse.redirect(url); + } else { + return NextResponse.json( + { error: "An error occurred!" }, + { status: 500 }, + ); + } + } catch (error: unknown) { + // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) + console.error(error); + if ( + error && + typeof error === "object" && + "code" in error && + error?.code === 9 + ) { + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: selectedSession.factors?.user?.organizationId, + }); + + if (loginSettings?.defaultRedirectUri) { + return NextResponse.redirect(loginSettings.defaultRedirectUri); + } + + const signedinUrl = constructUrl(request, "/signedin"); + + if (selectedSession.factors?.user?.loginName) { + signedinUrl.searchParams.set( + "loginName", + selectedSession.factors?.user?.loginName, + ); + } + if (selectedSession.factors?.user?.organizationId) { + signedinUrl.searchParams.set( + "organization", + selectedSession.factors?.user?.organizationId, + ); + } + return NextResponse.redirect(signedinUrl); + } else { + return NextResponse.json({ error }, { status: 500 }); + } + } + } + } +} diff --git a/login/apps/login/src/lib/self.ts b/login/apps/login/src/lib/self.ts new file mode 100644 index 0000000000..df8508c29e --- /dev/null +++ b/login/apps/login/src/lib/self.ts @@ -0,0 +1,60 @@ +"use server"; + +import { createUserServiceClient } from "@zitadel/client/v2"; +import { headers } from "next/headers"; +import { getSessionCookieById } from "./cookies"; +import { getServiceUrlFromHeaders } from "./service-url"; +import { createServerTransport, getSession } from "./zitadel"; + +const myUserService = async (serviceUrl: string, sessionToken: string) => { + const transportPromise = await createServerTransport( + sessionToken, + serviceUrl, + ); + return createUserServiceClient(transportPromise); +}; + +export async function setMyPassword({ + sessionId, + password, +}: { + sessionId: string; + password: string; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionCookie = await getSessionCookieById({ sessionId }); + + const { session } = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session) { + return { error: "Could not load session" }; + } + + const service = await myUserService(serviceUrl, `${sessionCookie.token}`); + + if (!session?.factors?.user?.id) { + return { error: "No user id found in session" }; + } + + return service + .setPassword( + { + userId: session.factors.user.id, + newPassword: { password, changeRequired: false }, + }, + {}, + ) + .catch((error) => { + console.log(error); + if (error.code === 7) { + return { error: "Session is not valid." }; + } + throw error; + }); +} diff --git a/login/apps/login/src/lib/server/cookie.ts b/login/apps/login/src/lib/server/cookie.ts new file mode 100644 index 0000000000..841fc06b3a --- /dev/null +++ b/login/apps/login/src/lib/server/cookie.ts @@ -0,0 +1,278 @@ +"use server"; + +import { addSessionToCookie, updateSessionCookie } from "@/lib/cookies"; +import { + createSessionForUserIdAndIdpIntent, + createSessionFromChecks, + getSecuritySettings, + getSession, + setSession, +} from "@/lib/zitadel"; +import { ConnectError, Duration, timestampMs } from "@zitadel/client"; +import { + CredentialsCheckError, + CredentialsCheckErrorSchema, + ErrorDetail, +} from "@zitadel/proto/zitadel/message_pb"; +import { + Challenges, + RequestChallenges, +} from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service-url"; + +type CustomCookieData = { + id: string; + token: string; + loginName: string; + organization?: string; + creationTs: string; + expirationTs: string; + changeTs: string; + requestId?: string; // if its linked to an OIDC flow +}; + +const passwordAttemptsHandler = (error: ConnectError) => { + const details = error.findDetails(CredentialsCheckErrorSchema); + + if (details[0] && "failedAttempts" in details[0]) { + const failedAttempts = details[0].failedAttempts; + throw { + error: `Failed to authenticate: You had ${failedAttempts} password attempts.`, + failedAttempts: failedAttempts, + }; + } + throw error; +}; + +export async function createSessionAndUpdateCookie(command: { + checks: Checks; + requestId: string | undefined; + lifetime?: Duration; +}): Promise { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const createdSession = await createSessionFromChecks({ + serviceUrl, + checks: command.checks, + lifetime: command.lifetime, + }); + + if (createdSession) { + return getSession({ + serviceUrl, + sessionId: createdSession.sessionId, + sessionToken: createdSession.sessionToken, + }).then(async (response) => { + if (response?.session && response.session?.factors?.user?.loginName) { + const sessionCookie: CustomCookieData = { + id: createdSession.sessionId, + token: createdSession.sessionToken, + creationTs: response.session.creationDate + ? `${timestampMs(response.session.creationDate)}` + : "", + expirationTs: response.session.expirationDate + ? `${timestampMs(response.session.expirationDate)}` + : "", + changeTs: response.session.changeDate + ? `${timestampMs(response.session.changeDate)}` + : "", + loginName: response.session.factors.user.loginName ?? "", + }; + + if (command.requestId) { + sessionCookie.requestId = command.requestId; + } + + if (response.session.factors.user.organizationId) { + sessionCookie.organization = + response.session.factors.user.organizationId; + } + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled + ? "none" + : true; + + await addSessionToCookie({ session: sessionCookie, sameSite }); + + return response.session as Session; + } else { + throw "could not get session or session does not have loginName"; + } + }); + } else { + throw "Could not create session"; + } +} + +export async function createSessionForIdpAndUpdateCookie({ + userId, + idpIntent, + requestId, + lifetime, +}: { + userId: string; + idpIntent: { + idpIntentId?: string | undefined; + idpIntentToken?: string | undefined; + }; + requestId: string | undefined; + lifetime?: Duration; +}): Promise { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const createdSession = await createSessionForUserIdAndIdpIntent({ + serviceUrl, + userId, + idpIntent, + lifetime, + }).catch((error: ErrorDetail | CredentialsCheckError) => { + console.error("Could not set session", error); + if ("failedAttempts" in error && error.failedAttempts) { + throw { + error: `Failed to authenticate: You had ${error.failedAttempts} password attempts.`, + failedAttempts: error.failedAttempts, + }; + } + throw error; + }); + + if (!createdSession) { + throw "Could not create session"; + } + + const { session } = await getSession({ + serviceUrl, + sessionId: createdSession.sessionId, + sessionToken: createdSession.sessionToken, + }); + + if (!session || !session.factors?.user?.loginName) { + throw "Could not retrieve session"; + } + + const sessionCookie: CustomCookieData = { + id: createdSession.sessionId, + token: createdSession.sessionToken, + creationTs: session.creationDate + ? `${timestampMs(session.creationDate)}` + : "", + expirationTs: session.expirationDate + ? `${timestampMs(session.expirationDate)}` + : "", + changeTs: session.changeDate ? `${timestampMs(session.changeDate)}` : "", + loginName: session.factors.user.loginName ?? "", + organization: session.factors.user.organizationId ?? "", + }; + + if (requestId) { + sessionCookie.requestId = requestId; + } + + if (session.factors.user.organizationId) { + sessionCookie.organization = session.factors.user.organizationId; + } + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true; + + return addSessionToCookie({ session: sessionCookie, sameSite }).then(() => { + return session as Session; + }); +} + +export type SessionWithChallenges = Session & { + challenges: Challenges | undefined; +}; + +export async function setSessionAndUpdateCookie( + recentCookie: CustomCookieData, + checks?: Checks, + challenges?: RequestChallenges, + requestId?: string, + lifetime?: Duration, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return setSession({ + serviceUrl, + sessionId: recentCookie.id, + sessionToken: recentCookie.token, + challenges, + checks, + lifetime, + }) + .then((updatedSession) => { + if (updatedSession) { + const sessionCookie: CustomCookieData = { + id: recentCookie.id, + token: updatedSession.sessionToken, + creationTs: recentCookie.creationTs, + expirationTs: recentCookie.expirationTs, + // just overwrite the changeDate with the new one + changeTs: updatedSession.details?.changeDate + ? `${timestampMs(updatedSession.details.changeDate)}` + : "", + loginName: recentCookie.loginName, + organization: recentCookie.organization, + }; + + if (requestId) { + sessionCookie.requestId = requestId; + } + + return getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }).then(async (response) => { + if ( + !response?.session || + !response.session.factors?.user?.loginName + ) { + throw "could not get session or session does not have loginName"; + } + + const { session } = response; + const newCookie: CustomCookieData = { + id: sessionCookie.id, + token: updatedSession.sessionToken, + creationTs: sessionCookie.creationTs, + expirationTs: sessionCookie.expirationTs, + // just overwrite the changeDate with the new one + changeTs: updatedSession.details?.changeDate + ? `${timestampMs(updatedSession.details.changeDate)}` + : "", + loginName: session.factors?.user?.loginName ?? "", + organization: session.factors?.user?.organizationId ?? "", + }; + + if (sessionCookie.requestId) { + newCookie.requestId = sessionCookie.requestId; + } + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled + ? "none" + : true; + + return updateSessionCookie({ + id: sessionCookie.id, + session: newCookie, + sameSite, + }).then(() => { + return { challenges: updatedSession.challenges, ...session }; + }); + }); + } else { + throw "Session not be set"; + } + }) + .catch(passwordAttemptsHandler); +} diff --git a/login/apps/login/src/lib/server/device.ts b/login/apps/login/src/lib/server/device.ts new file mode 100644 index 0000000000..5e36facfc8 --- /dev/null +++ b/login/apps/login/src/lib/server/device.ts @@ -0,0 +1,20 @@ +"use server"; + +import { authorizeOrDenyDeviceAuthorization } from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service-url"; + +export async function completeDeviceAuthorization( + deviceAuthorizationId: string, + session?: { sessionId: string; sessionToken: string }, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // without the session, device auth request is denied + return authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId, + session, + }); +} diff --git a/login/apps/login/src/lib/server/idp.ts b/login/apps/login/src/lib/server/idp.ts new file mode 100644 index 0000000000..87f88a7c32 --- /dev/null +++ b/login/apps/login/src/lib/server/idp.ts @@ -0,0 +1,241 @@ +"use server"; + +import { + getLoginSettings, + getUserByID, + startIdentityProviderFlow, + startLDAPIdentityProviderFlow, +} from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { getNextUrl } from "../client"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { checkEmailVerification } from "../verify-helper"; +import { createSessionForIdpAndUpdateCookie } from "./cookie"; + +export type RedirectToIdpState = { error?: string | null } | undefined; + +export async function redirectToIdp( + prevState: RedirectToIdpState, + formData: FormData, +): Promise { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + if (!host) { + return { error: "Could not get host" }; + } + + const params = new URLSearchParams(); + + const linkOnly = formData.get("linkOnly") === "true"; + const requestId = formData.get("requestId") as string; + const organization = formData.get("organization") as string; + const idpId = formData.get("id") as string; + const provider = formData.get("provider") as string; + + if (linkOnly) params.set("link", "true"); + if (requestId) params.set("requestId", requestId); + if (organization) params.set("organization", organization); + + // redirect to LDAP page where username and password is requested + if (provider === "ldap") { + params.set("idpId", idpId); + redirect(`/idp/ldap?` + params.toString()); + } + + const response = await startIDPFlow({ + serviceUrl, + host, + idpId, + successUrl: `/idp/${provider}/success?` + params.toString(), + failureUrl: `/idp/${provider}/failure?` + params.toString(), + }); + + if (!response) { + return { error: "Could not start IDP flow" }; + } + + if (response && "redirect" in response && response?.redirect) { + redirect(response.redirect); + } + + return { error: "Unexpected response from IDP flow" }; +} + +export type StartIDPFlowCommand = { + serviceUrl: string; + host: string; + idpId: string; + successUrl: string; + failureUrl: string; +}; + +async function startIDPFlow(command: StartIDPFlowCommand) { + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + const url = await startIdentityProviderFlow({ + serviceUrl: command.serviceUrl, + idpId: command.idpId, + urls: { + successUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.successUrl}`, + failureUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.failureUrl}`, + }, + }); + + if (!url) { + return { error: "Could not start IDP flow" }; + } + + return { redirect: url }; +} + +type CreateNewSessionCommand = { + userId: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + loginName?: string; + password?: string; + organization?: string; + requestId?: string; +}; + +export async function createNewSessionFromIdpIntent( + command: CreateNewSessionCommand, +) { + const _headers = await headers(); + + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get domain" }; + } + + if (!command.userId || !command.idpIntent) { + throw new Error("No userId or loginName provided"); + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: command.userId, + }); + + if (!userResponse || !userResponse.user) { + return { error: "User not found in the system" }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: userResponse.user.details?.resourceOwner, + }); + + const session = await createSessionForIdpAndUpdateCookie({ + userId: command.userId, + idpIntent: command.idpIntent, + requestId: command.requestId, + lifetime: loginSettings?.externalLoginCheckLifetime, + }); + + if (!session || !session.factors?.user) { + return { error: "Could not create session" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + // check to see if user was verified + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + command.organization, + command.requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + // TODO: check if user has MFA methods + // const mfaFactorCheck = checkMFAFactors(session, loginSettings, authMethods, organization, requestId); + // if (mfaFactorCheck?.redirect) { + // return mfaFactorCheck; + // } + + const url = await getNextUrl( + command.requestId && session.id + ? { + sessionId: session.id, + requestId: command.requestId, + organization: session.factors.user.organizationId, + } + : { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + if (url) { + return { redirect: url }; + } +} + +type createNewSessionForLDAPCommand = { + username: string; + password: string; + idpId: string; + link: boolean; +}; + +export async function createNewSessionForLDAP( + command: createNewSessionForLDAPCommand, +) { + const _headers = await headers(); + + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get domain" }; + } + + if (!command.username || !command.password) { + return { error: "No username or password provided" }; + } + + const response = await startLDAPIdentityProviderFlow({ + serviceUrl, + idpId: command.idpId, + username: command.username, + password: command.password, + }); + + if ( + !response || + response.nextStep.case !== "idpIntent" || + !response.nextStep.value + ) { + return { error: "Could not start LDAP identity provider flow" }; + } + + const { userId, idpIntentId, idpIntentToken } = response.nextStep.value; + + const params = new URLSearchParams({ + userId, + id: idpIntentId, + token: idpIntentToken, + }); + + if (command.link) { + params.set("link", "true"); + } + + return { + redirect: `/idp/ldap/success?` + params.toString(), + }; +} diff --git a/login/apps/login/src/lib/server/loginname.ts b/login/apps/login/src/lib/server/loginname.ts new file mode 100644 index 0000000000..dee740bf4f --- /dev/null +++ b/login/apps/login/src/lib/server/loginname.ts @@ -0,0 +1,466 @@ +"use server"; + +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp"; + +import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { + getActiveIdentityProviders, + getIDPByID, + getLoginSettings, + getOrgsByDomain, + listAuthenticationMethodTypes, + listIDPLinks, + searchUsers, + SearchUsersCommand, + startIdentityProviderFlow, +} from "../zitadel"; +import { createSessionAndUpdateCookie } from "./cookie"; + +export type SendLoginnameCommand = { + loginName: string; + requestId?: string; + organization?: string; + suffix?: string; +}; + +const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; + +export async function sendLoginname(command: SendLoginnameCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + throw new Error("Could not get domain"); + } + + const loginSettingsByContext = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + if (!loginSettingsByContext) { + return { error: "Could not get login settings" }; + } + + let searchUsersRequest: SearchUsersCommand = { + serviceUrl, + searchValue: command.loginName, + organizationId: command.organization, + loginSettings: loginSettingsByContext, + suffix: command.suffix, + }; + + const searchResult = await searchUsers(searchUsersRequest); + + if ("error" in searchResult && searchResult.error) { + return searchResult; + } + + if (!("result" in searchResult)) { + return { error: "Could not search users" }; + } + + const { result: potentialUsers } = searchResult; + + const redirectUserToSingleIDPIfAvailable = async () => { + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: command.organization, + }).then((resp) => { + return resp.identityProviders; + }); + + if (identityProviders.length === 1) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + const identityProviderType = identityProviders[0].type; + + const provider = idpTypeToSlug(identityProviderType); + + const params = new URLSearchParams(); + + if (command.requestId) { + params.set("requestId", command.requestId); + } + + if (command.organization) { + params.set("organization", command.organization); + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + const url = await startIdentityProviderFlow({ + serviceUrl, + idpId: identityProviders[0].id, + urls: { + successUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` + + new URLSearchParams(params), + failureUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` + + new URLSearchParams(params), + }, + }); + + if (!url) { + return { error: "Could not start IDP flow" }; + } + + return { redirect: url }; + } + }; + + const redirectUserToIDP = async (userId: string) => { + const identityProviders = await listIDPLinks({ + serviceUrl, + userId, + }).then((resp) => { + return resp.result; + }); + + if (identityProviders.length === 1) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + const identityProviderId = identityProviders[0].idpId; + + const idp = await getIDPByID({ + serviceUrl, + id: identityProviderId, + }); + + const idpType = idp?.type; + + if (!idp || !idpType) { + throw new Error("Could not find identity provider"); + } + + const identityProviderType = idpTypeToIdentityProviderType(idpType); + const provider = idpTypeToSlug(identityProviderType); + + const params = new URLSearchParams({ userId }); + + if (command.requestId) { + params.set("requestId", command.requestId); + } + + if (command.organization) { + params.set("organization", command.organization); + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + const url = await startIdentityProviderFlow({ + serviceUrl, + idpId: idp.id, + urls: { + successUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` + + new URLSearchParams(params), + failureUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` + + new URLSearchParams(params), + }, + }); + + if (!url) { + return { error: "Could not start IDP flow" }; + } + + return { redirect: url }; + } + }; + + if (potentialUsers.length > 1) { + return { error: "More than one user found. Provide a unique identifier." }; + } else if (potentialUsers.length == 1 && potentialUsers[0].userId) { + const user = potentialUsers[0]; + const userId = potentialUsers[0].userId; + + const userLoginSettings = await getLoginSettings({ + serviceUrl, + organization: user.details?.resourceOwner, + }); + + // compare with the concatenated suffix when set + const concatLoginname = command.suffix + ? `${command.loginName}@${command.suffix}` + : command.loginName; + + const humanUser = + potentialUsers[0].type.case === "human" + ? potentialUsers[0].type.value + : undefined; + + // recheck login settings after user discovery, as the search might have been done without org scope + if ( + userLoginSettings?.disableLoginWithEmail && + userLoginSettings?.disableLoginWithPhone + ) { + if (user.preferredLoginName !== concatLoginname) { + return { error: "User not found in the system!" }; + } + } else if (userLoginSettings?.disableLoginWithEmail) { + if ( + user.preferredLoginName !== concatLoginname || + humanUser?.phone?.phone !== command.loginName + ) { + return { error: "User not found in the system!" }; + } + } else if (userLoginSettings?.disableLoginWithPhone) { + if ( + user.preferredLoginName !== concatLoginname || + humanUser?.email?.email !== command.loginName + ) { + return { error: "User not found in the system!" }; + } + } + + const checks = create(ChecksSchema, { + user: { search: { case: "userId", value: userId } }, + }); + + const session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + }); + + if (!session.factors?.user?.id) { + return { error: "Could not create session for user" }; + } + + // TODO: check if handling of userstate INITIAL is needed + if (user.state === UserState.INITIAL) { + return { error: "Initial User not supported" }; + } + + const methods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors?.user?.id, + }); + + // always resend invite if user has no auth method set + if (!methods.authMethodTypes || !methods.authMethodTypes.length) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + send: "true", // set this to true to request a new code immediately + invite: "true", + }); + + if (command.requestId) { + params.append("requestId", command.requestId); + } + + if (command.organization || session.factors?.user?.organizationId) { + params.append( + "organization", + command.organization ?? + (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/verify?` + params }; + } + + if (methods.authMethodTypes.length == 1) { + const method = methods.authMethodTypes[0]; + switch (method) { + case AuthenticationMethodType.PASSWORD: // user has only password as auth method + if (!userLoginSettings?.allowUsernamePassword) { + return { + error: + "Username Password not allowed! Contact your administrator for more information.", + }; + } + + const paramsPassword = new URLSearchParams({ + loginName: session.factors?.user?.loginName, + }); + + // TODO: does this have to be checked in loginSettings.allowDomainDiscovery + + if (command.organization || session.factors?.user?.organizationId) { + paramsPassword.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); + } + + if (command.requestId) { + paramsPassword.append("requestId", command.requestId); + } + + return { + redirect: "/password?" + paramsPassword, + }; + + case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY + if (userLoginSettings?.passkeysType === PasskeysType.NOT_ALLOWED) { + return { + error: + "Passkeys not allowed! Contact your administrator for more information.", + }; + } + + const paramsPasskey = new URLSearchParams({ + loginName: session.factors?.user?.loginName, + }); + if (command.requestId) { + paramsPasskey.append("requestId", command.requestId); + } + + if (command.organization || session.factors?.user?.organizationId) { + paramsPasskey.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); + } + + return { redirect: "/passkey?" + paramsPasskey }; + } + } else { + // prefer passkey in favor of other methods + if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY)) { + const passkeyParams = new URLSearchParams({ + loginName: session.factors?.user?.loginName, + altPassword: `${methods.authMethodTypes.includes(1)}`, // show alternative password option + }); + + if (command.requestId) { + passkeyParams.append("requestId", command.requestId); + } + + if (command.organization || session.factors?.user?.organizationId) { + passkeyParams.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); + } + + return { redirect: "/passkey?" + passkeyParams }; + } else if ( + methods.authMethodTypes.includes(AuthenticationMethodType.IDP) + ) { + return redirectUserToIDP(userId); + } else if ( + methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) + ) { + // user has no passkey setup and login settings allow passkeys + const paramsPasswordDefault = new URLSearchParams({ + loginName: session.factors?.user?.loginName, + }); + + if (command.requestId) { + paramsPasswordDefault.append("requestId", command.requestId); + } + + if (command.organization || session.factors?.user?.organizationId) { + paramsPasswordDefault.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); + } + + return { + redirect: "/password?" + paramsPasswordDefault, + }; + } + } + } + + // user not found, check if register is enabled on instance / organization context + if ( + loginSettingsByContext?.allowRegister && + !loginSettingsByContext?.allowUsernamePassword + ) { + const resp = await redirectUserToSingleIDPIfAvailable(); + if (resp) { + return resp; + } + return { error: "User not found in the system" }; + } else if ( + loginSettingsByContext?.allowRegister && + loginSettingsByContext?.allowUsernamePassword + ) { + let orgToRegisterOn: string | undefined = command.organization; + + if ( + !loginSettingsByContext?.ignoreUnknownUsernames && + !orgToRegisterOn && + command.loginName && + ORG_SUFFIX_REGEX.test(command.loginName) + ) { + const matched = ORG_SUFFIX_REGEX.exec(command.loginName); + const suffix = matched?.[1] ?? ""; + + // this just returns orgs where the suffix is set as primary domain + const orgs = await getOrgsByDomain({ + serviceUrl, + domain: suffix, + }); + const orgToCheckForDiscovery = + orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + + const orgLoginSettings = await getLoginSettings({ + serviceUrl, + organization: orgToCheckForDiscovery, + }); + if (orgLoginSettings?.allowDomainDiscovery) { + orgToRegisterOn = orgToCheckForDiscovery; + } + } + + // do not register user if ignoreUnknownUsernames is set + if (orgToRegisterOn && !loginSettingsByContext?.ignoreUnknownUsernames) { + const params = new URLSearchParams({ organization: orgToRegisterOn }); + + if (command.requestId) { + params.set("requestId", command.requestId); + } + + if (command.loginName) { + params.set("email", command.loginName); + } + + return { redirect: "/register?" + params }; + } + } + + if (loginSettingsByContext?.ignoreUnknownUsernames) { + const paramsPasswordDefault = new URLSearchParams({ + loginName: command.loginName, + }); + + if (command.requestId) { + paramsPasswordDefault.append("requestId", command.requestId); + } + + if (command.organization) { + paramsPasswordDefault.append("organization", command.organization); + } + + return { redirect: "/password?" + paramsPasswordDefault }; + } + + // fallbackToPassword + + return { error: "User not found in the system" }; +} diff --git a/login/apps/login/src/lib/server/oidc.ts b/login/apps/login/src/lib/server/oidc.ts new file mode 100644 index 0000000000..36a31fe419 --- /dev/null +++ b/login/apps/login/src/lib/server/oidc.ts @@ -0,0 +1,15 @@ +"use server"; + +import { getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest } from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service-url"; + +export async function getDeviceAuthorizationRequest(userCode: string) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return zitadelGetDeviceAuthorizationRequest({ + serviceUrl, + userCode, + }); +} diff --git a/login/apps/login/src/lib/server/otp.ts b/login/apps/login/src/lib/server/otp.ts new file mode 100644 index 0000000000..f3d4a1536a --- /dev/null +++ b/login/apps/login/src/lib/server/otp.ts @@ -0,0 +1,83 @@ +"use server"; + +import { setSessionAndUpdateCookie } from "@/lib/server/cookie"; +import { create } from "@zitadel/client"; +import { + CheckOTPSchema, + ChecksSchema, + CheckTOTPSchema, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, +} from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { getLoginSettings } from "../zitadel"; + +export type SetOTPCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + requestId?: string; + code: string; + method: string; +}; + +export async function setOTP(command: SetOTPCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const recentSession = command.sessionId + ? await getSessionCookieById({ sessionId: command.sessionId }).catch( + (error) => { + return Promise.reject(error); + }, + ) + : command.loginName + ? await getSessionCookieByLoginName({ + loginName: command.loginName, + organization: command.organization, + }).catch((error) => { + return Promise.reject(error); + }) + : await getMostRecentSessionCookie().catch((error) => { + return Promise.reject(error); + }); + + const checks = create(ChecksSchema, {}); + + if (command.method === "time-based") { + checks.totp = create(CheckTOTPSchema, { + code: command.code, + }); + } else if (command.method === "sms") { + checks.otpSms = create(CheckOTPSchema, { + code: command.code, + }); + } else if (command.method === "email") { + checks.otpEmail = create(CheckOTPSchema, { + code: command.code, + }); + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + return setSessionAndUpdateCookie( + recentSession, + checks, + undefined, + command.requestId, + loginSettings?.secondFactorCheckLifetime, + ).then((session) => { + return { + sessionId: session.id, + factors: session.factors, + challenges: session.challenges, + }; + }); +} diff --git a/login/apps/login/src/lib/server/passkeys.ts b/login/apps/login/src/lib/server/passkeys.ts new file mode 100644 index 0000000000..3470629f24 --- /dev/null +++ b/login/apps/login/src/lib/server/passkeys.ts @@ -0,0 +1,278 @@ +"use server"; + +import { + createPasskeyRegistrationLink, + getLoginSettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, + registerPasskey, + verifyPasskeyRegistration as zitadelVerifyPasskeyRegistration, +} from "@/lib/zitadel"; +import { create, Duration, Timestamp, timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { + RegisterPasskeyResponse, + VerifyPasskeyRegistrationRequestSchema, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { userAgent } from "next/server"; +import { getNextUrl } from "../client"; +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, +} from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { + checkEmailVerification, + checkUserVerification, +} from "../verify-helper"; +import { setSessionAndUpdateCookie } from "./cookie"; + +type VerifyPasskeyCommand = { + passkeyId: string; + passkeyName?: string; + publicKeyCredential: any; + sessionId: string; +}; + +type RegisterPasskeyCommand = { + sessionId: string; +}; + +function isSessionValid(session: Partial): { + valid: boolean; + verifiedAt?: Timestamp; +} { + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate) > new Date() + : true; + + const verifiedAt = validPassword || validPasskey; + const valid = !!((validPassword || validPasskey) && stillValid); + + return { valid, verifiedAt }; +} + +export async function registerPasskeyLink( + command: RegisterPasskeyCommand, +): Promise { + const { sessionId } = command; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + throw new Error("Could not get domain"); + } + + const sessionCookie = await getSessionCookieById({ sessionId }); + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session?.session?.factors?.user?.id) { + return { error: "Could not determine user from session" }; + } + + const sessionValid = isSessionValid(session.session); + + if (!sessionValid) { + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.session.factors.user.id, + }); + + // if the user has no authmethods set, we need to check if the user was verified + if (authmethods.authMethodTypes.length !== 0) { + return { + error: + "You have to authenticate or have a valid User Verification Check", + }; + } + + // check if a verification was done earlier + const hasValidUserVerificationCheck = await checkUserVerification( + session.session.factors.user.id, + ); + + if (!hasValidUserVerificationCheck) { + return { error: "User Verification Check has to be done" }; + } + } + + const [hostname, port] = host.split(":"); + + if (!hostname) { + throw new Error("Could not get hostname"); + } + + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + throw new Error("Could not get session"); + } + // TODO: add org context + + // use session token to add the passkey + const registerLink = await createPasskeyRegistrationLink({ + serviceUrl, + userId, + }); + + if (!registerLink.code) { + throw new Error("Missing code in response"); + } + + return registerPasskey({ + serviceUrl, + userId, + code: registerLink.code, + domain: hostname, + }); +} + +export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // if no name is provided, try to generate one from the user agent + let passkeyName = command.passkeyName; + if (!!!passkeyName) { + const headersList = await headers(); + const userAgentStructure = { headers: headersList }; + const { browser, device, os } = userAgent(userAgentStructure); + + passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${ + device.vendor || device.model ? ", " : "" + }${os.name}${os.name ? ", " : ""}${browser.name}`; + } + + const sessionCookie = await getSessionCookieById({ + sessionId: command.sessionId, + }); + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + throw new Error("Could not get session"); + } + + return zitadelVerifyPasskeyRegistration({ + serviceUrl, + request: create(VerifyPasskeyRegistrationRequestSchema, { + passkeyId: command.passkeyId, + publicKeyCredential: command.publicKeyCredential, + passkeyName, + userId, + }), + }); +} + +type SendPasskeyCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + checks?: Checks; + requestId?: string; + lifetime?: Duration; +}; + +export async function sendPasskey(command: SendPasskeyCommand) { + let { loginName, sessionId, organization, checks, requestId } = command; + const recentSession = sessionId + ? await getSessionCookieById({ sessionId }) + : loginName + ? await getSessionCookieByLoginName({ loginName, organization }) + : await getMostRecentSessionCookie(); + + if (!recentSession) { + return { + error: "Could not find session", + }; + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const lifetime = checks?.webAuthN + ? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey + : checks?.otpEmail || checks?.otpSms + ? loginSettings?.secondFactorCheckLifetime + : undefined; + + const session = await setSessionAndUpdateCookie( + recentSession, + checks, + undefined, + requestId, + lifetime, + ); + + if (!session || !session?.factors?.user?.id) { + return { error: "Could not update session" }; + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: session?.factors?.user?.id, + }); + + if (!userResponse.user) { + return { error: "User not found in the system" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + organization, + requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + const url = + requestId && session.id + ? await getNextUrl( + { + sessionId: session.id, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : session?.factors?.user?.loginName + ? await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + + return { redirect: url }; +} diff --git a/login/apps/login/src/lib/server/password.ts b/login/apps/login/src/lib/server/password.ts new file mode 100644 index 0000000000..5c6fb03aa5 --- /dev/null +++ b/login/apps/login/src/lib/server/password.ts @@ -0,0 +1,460 @@ +"use server"; + +import { + createSessionAndUpdateCookie, + setSessionAndUpdateCookie, +} from "@/lib/server/cookie"; +import { + getLockoutSettings, + getLoginSettings, + getPasswordExpirySettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, + listUsers, + passwordReset, + setPassword, + setUserPassword, +} from "@/lib/zitadel"; +import { ConnectError, create } from "@zitadel/client"; +import { createUserServiceClient } from "@zitadel/client/v2"; +import { + Checks, + ChecksSchema, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { + AuthenticationMethodType, + SetPasswordRequestSchema, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { + checkEmailVerification, + checkMFAFactors, + checkPasswordChangeRequired, + checkUserVerification, +} from "../verify-helper"; +import { createServerTransport } from "../zitadel"; + +type ResetPasswordCommand = { + loginName: string; + organization?: string; + requestId?: string; +}; + +export async function resetPassword(command: ResetPasswordCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const users = await listUsers({ + serviceUrl, + loginName: command.loginName, + organizationId: command.organization, + }); + + if ( + !users.details || + users.details.totalResult !== BigInt(1) || + !users.result[0].userId + ) { + return { error: "Could not send Password Reset Link" }; + } + const userId = users.result[0].userId; + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + return passwordReset({ + serviceUrl, + userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + (command.requestId ? `&requestId=${command.requestId}` : ""), + }); +} + +export type UpdateSessionCommand = { + loginName: string; + organization?: string; + checks: Checks; + requestId?: string; +}; + +export async function sendPassword(command: UpdateSessionCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let sessionCookie = await getSessionCookieByLoginName({ + loginName: command.loginName, + organization: command.organization, + }).catch((error) => { + console.warn("Ignored error:", error); + }); + + let session; + let user: User; + let loginSettings: LoginSettings | undefined; + + if (!sessionCookie) { + const users = await listUsers({ + serviceUrl, + loginName: command.loginName, + organizationId: command.organization, + }); + + if (users.details?.totalResult == BigInt(1) && users.result[0].userId) { + user = users.result[0]; + + const checks = create(ChecksSchema, { + user: { search: { case: "userId", value: users.result[0].userId } }, + password: { password: command.checks.password?.password }, + }); + + loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + try { + session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + lifetime: loginSettings?.passwordCheckLifetime, + }); + } catch (error: any) { + if ("failedAttempts" in error && error.failedAttempts) { + const lockoutSettings = await getLockoutSettings({ + serviceUrl, + orgId: command.organization, + }); + + return { + error: + `Failed to authenticate. You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.` + + (lockoutSettings?.maxPasswordAttempts && + error.failedAttempts >= lockoutSettings?.maxPasswordAttempts + ? "Contact your administrator to unlock your account" + : ""), + }; + } + return { error: "Could not create session for user" }; + } + } + + // this is a fake error message to hide that the user does not even exist + return { error: "Could not verify password" }; + } else { + try { + session = await setSessionAndUpdateCookie( + sessionCookie, + command.checks, + undefined, + command.requestId, + loginSettings?.passwordCheckLifetime, + ); + } catch (error: any) { + if ("failedAttempts" in error && error.failedAttempts) { + const lockoutSettings = await getLockoutSettings({ + serviceUrl, + orgId: command.organization, + }); + + return { + error: + `Failed to authenticate. You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.` + + (lockoutSettings?.maxPasswordAttempts && + error.failedAttempts >= lockoutSettings?.maxPasswordAttempts + ? " Contact your administrator to unlock your account" + : ""), + }; + } + throw error; + } + + if (!session?.factors?.user?.id) { + return { error: "Could not create session for user" }; + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: session?.factors?.user?.id, + }); + + if (!userResponse.user) { + return { error: "User not found in the system" }; + } + + user = userResponse.user; + } + + if (!loginSettings) { + loginSettings = await getLoginSettings({ + serviceUrl, + organization: + command.organization ?? session.factors?.user?.organizationId, + }); + } + + if (!session?.factors?.user?.id || !sessionCookie) { + return { error: "Could not create session for user" }; + } + + const humanUser = user.type.case === "human" ? user.type.value : undefined; + + const expirySettings = await getPasswordExpirySettings({ + serviceUrl, + orgId: command.organization ?? session.factors?.user?.organizationId, + }); + + // check if the user has to change password first + const passwordChangedCheck = checkPasswordChangeRequired( + expirySettings, + session, + humanUser, + command.organization, + command.requestId, + ); + + if (passwordChangedCheck?.redirect) { + return passwordChangedCheck; + } + + // throw error if user is in initial state here and do not continue + if (user.state === UserState.INITIAL) { + return { error: "Initial User not supported" }; + } + + // check to see if user was verified + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + command.organization, + command.requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + // if password, check if user has MFA methods + let authMethods; + if (command.checks && command.checks.password && session.factors?.user?.id) { + const response = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + if (response.authMethodTypes && response.authMethodTypes.length) { + authMethods = response.authMethodTypes; + } + } + + if (!authMethods) { + return { error: "Could not verify password!" }; + } + + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, + session, + loginSettings, + authMethods, + command.organization, + command.requestId, + ); + + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + + if (command.requestId && session.id) { + const nextUrl = await getNextUrl( + { + sessionId: session.id, + requestId: command.requestId, + organization: + command.organization ?? session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: nextUrl }; + } + + const url = await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; +} + +// this function lets users with code set a password or users with valid User Verification Check +export async function changePassword(command: { + code?: string; + userId: string; + password: string; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // check for init state + const { user } = await getUserByID({ + serviceUrl, + userId: command.userId, + }); + + if (!user || user.userId !== command.userId) { + return { error: "Could not send Password Reset Link" }; + } + const userId = user.userId; + + if (user.state === UserState.INITIAL) { + return { error: "User Initial State is not supported" }; + } + + // check if the user has no password set in order to set a password + if (!command.code) { + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId, + }); + + // if the user has no authmethods set, we need to check if the user was verified + if (authmethods.authMethodTypes.length !== 0) { + return { + error: + "You have to provide a code or have a valid User Verification Check", + }; + } + + // check if a verification was done earlier + const hasValidUserVerificationCheck = await checkUserVerification( + user.userId, + ); + + if (!hasValidUserVerificationCheck) { + return { error: "User Verification Check has to be done" }; + } + } + + return setUserPassword({ + serviceUrl, + userId, + password: command.password, + code: command.code, + }); +} + +type CheckSessionAndSetPasswordCommand = { + sessionId: string; + password: string; +}; + +export async function checkSessionAndSetPassword({ + sessionId, + password, +}: CheckSessionAndSetPasswordCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionCookie = await getSessionCookieById({ sessionId }); + + const { session } = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session || !session.factors?.user?.id) { + return { error: "Could not load session" }; + } + + const payload = create(SetPasswordRequestSchema, { + userId: session.factors.user.id, + newPassword: { + password, + }, + }); + + // check if the user has no password set in order to set a password + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + + if (!authmethods) { + return { error: "Could not load auth methods" }; + } + + const requiredAuthMethodsForForceMFA = [ + AuthenticationMethodType.OTP_EMAIL, + AuthenticationMethodType.OTP_SMS, + AuthenticationMethodType.TOTP, + AuthenticationMethodType.U2F, + ]; + + const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every( + (method) => !authmethods.authMethodTypes.includes(method), + ); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: session.factors.user.organizationId, + }); + + const forceMfa = !!( + loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly + ); + + // if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user + if (forceMfa && hasNoMFAMethods) { + return setPassword({ serviceUrl, payload }).catch((error) => { + // throw error if failed precondition (ex. User is not yet initialized) + if (error.code === 9 && error.message) { + return { error: "Failed precondition" }; + } else { + throw error; + } + }); + } else { + const transport = async (serviceUrl: string, token: string) => { + return createServerTransport(token, serviceUrl); + }; + + const myUserService = async (serviceUrl: string, sessionToken: string) => { + const transportPromise = await transport(serviceUrl, sessionToken); + return createUserServiceClient(transportPromise); + }; + + const selfService = await myUserService( + serviceUrl, + `${sessionCookie.token}`, + ); + + return selfService + .setPassword( + { + userId: session.factors.user.id, + newPassword: { password, changeRequired: false }, + }, + {}, + ) + .catch((error: ConnectError) => { + console.log(error); + if (error.code === 7) { + return { error: "Session is not valid." }; + } + throw error; + }); + } +} diff --git a/login/apps/login/src/lib/server/register.ts b/login/apps/login/src/lib/server/register.ts new file mode 100644 index 0000000000..f84b4c8d51 --- /dev/null +++ b/login/apps/login/src/lib/server/register.ts @@ -0,0 +1,233 @@ +"use server"; + +import { + createSessionAndUpdateCookie, + createSessionForIdpAndUpdateCookie, +} from "@/lib/server/cookie"; +import { + addHumanUser, + addIDPLink, + getLoginSettings, + getUserByID, +} from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { + ChecksJson, + ChecksSchema, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { checkEmailVerification } from "../verify-helper"; + +type RegisterUserCommand = { + email: string; + firstName: string; + lastName: string; + password?: string; + organization: string; + requestId?: string; +}; + +export type RegisterUserResponse = { + userId: string; + sessionId: string; + factors: Factors | undefined; +}; +export async function registerUser(command: RegisterUserCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const addResponse = await addHumanUser({ + serviceUrl, + email: command.email, + firstName: command.firstName, + lastName: command.lastName, + password: command.password ? command.password : undefined, + organization: command.organization, + }); + + if (!addResponse) { + return { error: "Could not create user" }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + let checkPayload: any = { + user: { search: { case: "userId", value: addResponse.userId } }, + }; + + if (command.password) { + checkPayload = { + ...checkPayload, + password: { password: command.password }, + } as ChecksJson; + } + + const checks = create(ChecksSchema, checkPayload); + + const session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + lifetime: command.password + ? loginSettings?.passwordCheckLifetime + : undefined, + }); + + if (!session || !session.factors?.user) { + return { error: "Could not create session" }; + } + + if (!command.password) { + const params = new URLSearchParams({ + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }); + + if (command.requestId) { + params.append("requestId", command.requestId); + } + + return { redirect: "/passkey/set?" + params }; + } else { + const userResponse = await getUserByID({ + serviceUrl, + userId: session?.factors?.user?.id, + }); + + if (!userResponse.user) { + return { error: "User not found in the system" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + session.factors.user.organizationId, + command.requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + const url = await getNextUrl( + command.requestId && session.id + ? { + sessionId: session.id, + requestId: command.requestId, + organization: session.factors.user.organizationId, + } + : { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; + } +} + +type RegisterUserAndLinkToIDPommand = { + email: string; + firstName: string; + lastName: string; + organization: string; + requestId?: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + idpUserId: string; + idpId: string; + idpUserName: string; +}; + +export type registerUserAndLinkToIDPResponse = { + userId: string; + sessionId: string; + factors: Factors | undefined; +}; +export async function registerUserAndLinkToIDP( + command: RegisterUserAndLinkToIDPommand, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const addResponse = await addHumanUser({ + serviceUrl, + email: command.email, + firstName: command.firstName, + lastName: command.lastName, + organization: command.organization, + }); + + if (!addResponse) { + return { error: "Could not create user" }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + const idpLink = await addIDPLink({ + serviceUrl, + idp: { + id: command.idpId, + userId: command.idpUserId, + userName: command.idpUserName, + }, + userId: addResponse.userId, + }); + + if (!idpLink) { + return { error: "Could not link IDP to user" }; + } + + const session = await createSessionForIdpAndUpdateCookie({ + requestId: command.requestId, + userId: addResponse.userId, // the user we just created + idpIntent: command.idpIntent, + lifetime: loginSettings?.externalLoginCheckLifetime, + }); + + if (!session || !session.factors?.user) { + return { error: "Could not create session" }; + } + + const url = await getNextUrl( + command.requestId && session.id + ? { + sessionId: session.id, + requestId: command.requestId, + organization: session.factors.user.organizationId, + } + : { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; +} diff --git a/login/apps/login/src/lib/server/session.ts b/login/apps/login/src/lib/server/session.ts new file mode 100644 index 0000000000..2aceb3a1d0 --- /dev/null +++ b/login/apps/login/src/lib/server/session.ts @@ -0,0 +1,221 @@ +"use server"; + +import { setSessionAndUpdateCookie } from "@/lib/server/cookie"; +import { + deleteSession, + getLoginSettings, + getSecuritySettings, + humanMFAInitSkipped, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { Duration } from "@zitadel/client"; +import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, + removeSessionFromCookie, +} from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; + +export async function skipMFAAndContinueWithNextUrl({ + userId, + requestId, + loginName, + sessionId, + organization, +}: { + userId: string; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization, + }); + + await humanMFAInitSkipped({ serviceUrl, userId }); + + const url = + requestId && sessionId + ? await getNextUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : loginName + ? await getNextUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + if (url) { + return { redirect: url }; + } +} + +export async function continueWithSession({ + requestId, + ...session +}: Session & { requestId?: string }) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: session.factors?.user?.organizationId, + }); + + const url = + requestId && session.id && session.factors?.user + ? await getNextUrl( + { + sessionId: session.id, + requestId: requestId, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ) + : session.factors?.user + ? await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + if (url) { + return { redirect: url }; + } +} + +export type UpdateSessionCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + checks?: Checks; + requestId?: string; + challenges?: RequestChallenges; + lifetime?: Duration; +}; + +export async function updateSession(options: UpdateSessionCommand) { + let { loginName, sessionId, organization, checks, requestId, challenges } = + options; + const recentSession = sessionId + ? await getSessionCookieById({ sessionId }) + : loginName + ? await getSessionCookieByLoginName({ loginName, organization }) + : await getMostRecentSessionCookie(); + + if (!recentSession) { + return { + error: "Could not find session", + }; + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + if ( + host && + challenges && + challenges.webAuthN && + !challenges.webAuthN.domain + ) { + const [hostname, port] = host.split(":"); + + challenges.webAuthN.domain = hostname; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const lifetime = checks?.webAuthN + ? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey + : checks?.otpEmail || checks?.otpSms + ? loginSettings?.secondFactorCheckLifetime + : undefined; + + const session = await setSessionAndUpdateCookie( + recentSession, + checks, + challenges, + requestId, + lifetime, + ); + + if (!session) { + return { error: "Could not update session" }; + } + + // if password, check if user has MFA methods + let authMethods; + if (checks && checks.password && session.factors?.user?.id) { + const response = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + if (response.authMethodTypes && response.authMethodTypes.length) { + authMethods = response.authMethodTypes; + } + } + + return { + sessionId: session.id, + factors: session.factors, + challenges: session.challenges, + authMethods, + }; +} + +type ClearSessionOptions = { + sessionId: string; +}; + +export async function clearSession(options: ClearSessionOptions) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { sessionId } = options; + + const sessionCookie = await getSessionCookieById({ sessionId }); + + const deleteResponse = await deleteSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true; + + if (!deleteResponse) { + throw new Error("Could not delete session"); + } + + return removeSessionFromCookie({ session: sessionCookie, sameSite }); +} diff --git a/login/apps/login/src/lib/server/u2f.ts b/login/apps/login/src/lib/server/u2f.ts new file mode 100644 index 0000000000..3fe5194336 --- /dev/null +++ b/login/apps/login/src/lib/server/u2f.ts @@ -0,0 +1,103 @@ +"use server"; + +import { getSession, registerU2F, verifyU2FRegistration } from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { VerifyU2FRegistrationRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { userAgent } from "next/server"; +import { getSessionCookieById } from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; + +type RegisterU2FCommand = { + sessionId: string; +}; + +type VerifyU2FCommand = { + u2fId: string; + passkeyName?: string; + publicKeyCredential: any; + sessionId: string; +}; + +export async function addU2F(command: RegisterU2FCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const sessionCookie = await getSessionCookieById({ + sessionId: command.sessionId, + }); + + if (!sessionCookie) { + return { error: "Could not get session" }; + } + + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + const [hostname, port] = host.split(":"); + + if (!hostname) { + throw new Error("Could not get hostname"); + } + + const userId = session?.session?.factors?.user?.id; + + if (!session || !userId) { + return { error: "Could not get session" }; + } + + return registerU2F({ serviceUrl, userId, domain: hostname }); +} + +export async function verifyU2F(command: VerifyU2FCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + let passkeyName = command.passkeyName; + if (!!!passkeyName) { + const headersList = await headers(); + const userAgentStructure = { headers: headersList }; + const { browser, device, os } = userAgent(userAgentStructure); + + passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${ + device.vendor || device.model ? ", " : "" + }${os.name}${os.name ? ", " : ""}${browser.name}`; + } + const sessionCookie = await getSessionCookieById({ + sessionId: command.sessionId, + }); + + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + return { error: "Could not get session" }; + } + + const request = create(VerifyU2FRegistrationRequestSchema, { + u2fId: command.u2fId, + publicKeyCredential: command.publicKeyCredential, + tokenName: passkeyName, + userId, + }); + + return verifyU2FRegistration({ serviceUrl, request }); +} diff --git a/login/apps/login/src/lib/server/verify.ts b/login/apps/login/src/lib/server/verify.ts new file mode 100644 index 0000000000..cf60f739b3 --- /dev/null +++ b/login/apps/login/src/lib/server/verify.ts @@ -0,0 +1,329 @@ +"use server"; + +import { + createInviteCode, + getLoginSettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, + verifyEmail, + verifyInviteCode, + verifyTOTPRegistration, + sendEmailCode as zitadelSendEmailCode, +} from "@/lib/zitadel"; +import crypto from "crypto"; + +import { create } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { cookies, headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { getSessionCookieByLoginName } from "../cookies"; +import { getOrSetFingerprintId } from "../fingerprint"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { loadMostRecentSession } from "../session"; +import { checkMFAFactors } from "../verify-helper"; +import { createSessionAndUpdateCookie } from "./cookie"; + +export async function verifyTOTP( + code: string, + loginName?: string, + organization?: string, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }).then((session) => { + if (session?.factors?.user?.id) { + return verifyTOTPRegistration({ + serviceUrl, + code, + userId: session.factors.user.id, + }); + } else { + throw Error("No user id found in session."); + } + }); +} + +type VerifyUserByEmailCommand = { + userId: string; + loginName?: string; // to determine already existing session + organization?: string; + code: string; + isInvite: boolean; + requestId?: string; +}; + +export async function sendVerification(command: VerifyUserByEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const verifyResponse = command.isInvite + ? await verifyInviteCode({ + serviceUrl, + userId: command.userId, + verificationCode: command.code, + }).catch((error) => { + console.warn(error); + return { error: "Could not verify invite" }; + }) + : await verifyEmail({ + serviceUrl, + userId: command.userId, + verificationCode: command.code, + }).catch((error) => { + console.warn(error); + return { error: "Could not verify email" }; + }); + + if ("error" in verifyResponse) { + return verifyResponse; + } + + if (!verifyResponse) { + return { error: "Could not verify" }; + } + + let session: Session | undefined; + const userResponse = await getUserByID({ + serviceUrl, + userId: command.userId, + }); + + if (!userResponse || !userResponse.user) { + return { error: "Could not load user" }; + } + + const user = userResponse.user; + + const sessionCookie = await getSessionCookieByLoginName({ + loginName: + "loginName" in command ? command.loginName : user.preferredLoginName, + organization: command.organization, + }).catch((error) => { + console.warn("Ignored error:", error); // checked later + }); + + if (sessionCookie) { + session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + } + + // load auth methods for user + const authMethodResponse = await listAuthenticationMethodTypes({ + serviceUrl, + userId: user.userId, + }); + + if (!authMethodResponse || !authMethodResponse.authMethodTypes) { + return { error: "Could not load possible authenticators" }; + } + + // if no authmethods are found on the user, redirect to set one up + if ( + authMethodResponse && + authMethodResponse.authMethodTypes && + authMethodResponse.authMethodTypes.length == 0 + ) { + if (!sessionCookie) { + const checks = create(ChecksSchema, { + user: { + search: { + case: "loginName", + value: userResponse.user.preferredLoginName, + }, + }, + }); + + session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + }); + } + + if (!session) { + return { error: "Could not create session" }; + } + + const params = new URLSearchParams({ + sessionId: session.id, + }); + + if (session.factors?.user?.loginName) { + params.set("loginName", session.factors?.user?.loginName); + } + + // set hash of userId and userAgentId to prevent attacks, checks are done for users with invalid sessions and invalid userAgentId + const cookiesList = await cookies(); + const userAgentId = await getOrSetFingerprintId(); + + const verificationCheck = crypto + .createHash("sha256") + .update(`${user.userId}:${userAgentId}`) + .digest("hex"); + + await cookiesList.set({ + name: "verificationCheck", + value: verificationCheck, + httpOnly: true, + path: "/", + maxAge: 300, // 5 minutes + }); + + return { redirect: `/authenticator/set?${params}` }; + } + + // if no session found only show success page, + // if user is invited, recreate invite flow to not depend on session + if (!session?.factors?.user?.id) { + const verifySuccessParams = new URLSearchParams({}); + + if (command.userId) { + verifySuccessParams.set("userId", command.userId); + } + + if ( + ("loginName" in command && command.loginName) || + user.preferredLoginName + ) { + verifySuccessParams.set( + "loginName", + "loginName" in command && command.loginName + ? command.loginName + : user.preferredLoginName, + ); + } + if (command.requestId) { + verifySuccessParams.set("requestId", command.requestId); + } + if (command.organization) { + verifySuccessParams.set("organization", command.organization); + } + + return { redirect: `/verify/success?${verifySuccessParams}` }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: user.details?.resourceOwner, + }); + + // redirect to mfa factor if user has one, or redirect to set one up + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, + session, + loginSettings, + authMethodResponse.authMethodTypes, + command.organization, + command.requestId, + ); + + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + + // login user if no additional steps are required + if (command.requestId && session.id) { + const nextUrl = await getNextUrl( + { + sessionId: session.id, + requestId: command.requestId, + organization: + command.organization ?? session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: nextUrl }; + } + + const url = await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; +} + +type resendVerifyEmailCommand = { + userId: string; + isInvite: boolean; + requestId?: string; +}; + +export async function resendVerification(command: resendVerifyEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "No host found" }; + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + return command.isInvite + ? createInviteCode({ + serviceUrl, + userId: command.userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + + (command.requestId ? `&requestId=${command.requestId}` : ""), + }).catch((error) => { + if (error.code === 9) { + return { error: "User is already verified!" }; + } + return { error: "Could not resend invite" }; + }) + : zitadelSendEmailCode({ + userId: command.userId, + serviceUrl, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + (command.requestId ? `&requestId=${command.requestId}` : ""), + }); +} + +type SendEmailCommand = { + userId: string; + urlTemplate: string; +}; + +export async function sendEmailCode(command: SendEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return zitadelSendEmailCode({ + serviceUrl, + userId: command.userId, + urlTemplate: command.urlTemplate, + }); +} + +export async function sendInviteEmailCode(command: SendEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return createInviteCode({ + serviceUrl, + userId: command.userId, + urlTemplate: command.urlTemplate, + }); +} diff --git a/login/apps/login/src/lib/service-url.ts b/login/apps/login/src/lib/service-url.ts new file mode 100644 index 0000000000..e74ee1f333 --- /dev/null +++ b/login/apps/login/src/lib/service-url.ts @@ -0,0 +1,58 @@ +import { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; +import { NextRequest } from "next/server"; + +/** + * Extracts the service url and region from the headers if used in a multitenant context (host, x-zitadel-forward-host header) + * or falls back to the ZITADEL_API_URL for a self hosting deployment + * or falls back to the host header for a self hosting deployment using custom domains + * @param headers + * @returns the service url and region from the headers + * @throws if the service url could not be determined + * + */ +export function getServiceUrlFromHeaders(headers: ReadonlyHeaders): { + serviceUrl: string; +} { + let instanceUrl; + + const forwardedHost = headers.get("x-zitadel-forward-host"); + // use the forwarded host if available (multitenant), otherwise fall back to the host of the deployment itself + if (forwardedHost) { + instanceUrl = forwardedHost; + instanceUrl = instanceUrl.startsWith("http://") + ? instanceUrl + : `https://${instanceUrl}`; + } else if (process.env.ZITADEL_API_URL) { + instanceUrl = process.env.ZITADEL_API_URL; + } else { + const host = headers.get("host"); + + if (host) { + const [hostname, port] = host.split(":"); + if (hostname !== "localhost") { + instanceUrl = host.startsWith("http") ? host : `https://${host}`; + } + } + } + + if (!instanceUrl) { + throw new Error("Service URL could not be determined"); + } + + return { + serviceUrl: instanceUrl, + }; +} + +export function constructUrl(request: NextRequest, path: string) { + const forwardedProto = request.headers.get("x-forwarded-proto") + ? `${request.headers.get("x-forwarded-proto")}:` + : request.nextUrl.protocol; + + const forwardedHost = + request.headers.get("x-zitadel-forward-host") ?? + request.headers.get("x-forwarded-host") ?? + request.headers.get("host"); + const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""; + return new URL(`${basePath}${path}`, `${forwardedProto}//${forwardedHost}`); +} diff --git a/login/apps/login/src/lib/service.ts b/login/apps/login/src/lib/service.ts new file mode 100644 index 0000000000..f7e81cc9d6 --- /dev/null +++ b/login/apps/login/src/lib/service.ts @@ -0,0 +1,49 @@ +import { createClientFor } from "@zitadel/client"; +import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; +import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb"; +import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; +import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { systemAPIToken } from "./api"; +import { createServerTransport } from "./zitadel"; + +type ServiceClass = + | typeof IdentityProviderService + | typeof UserService + | typeof OrganizationService + | typeof SessionService + | typeof OIDCService + | typeof SettingsService + | typeof SAMLService; + +export async function createServiceForHost( + service: T, + serviceUrl: string, +) { + let token; + + // if we are running in a multitenancy context, use the system user token + if ( + process.env.AUDIENCE && + process.env.SYSTEM_USER_ID && + process.env.SYSTEM_USER_PRIVATE_KEY + ) { + token = await systemAPIToken(); + } else if (process.env.ZITADEL_SERVICE_USER_TOKEN) { + token = process.env.ZITADEL_SERVICE_USER_TOKEN; + } + + if (!serviceUrl) { + throw new Error("No instance url found"); + } + + if (!token) { + throw new Error("No token found"); + } + + const transport = createServerTransport(token, serviceUrl); + + return createClientFor(service)(transport); +} diff --git a/login/apps/login/src/lib/session.ts b/login/apps/login/src/lib/session.ts new file mode 100644 index 0000000000..8c2548b8fb --- /dev/null +++ b/login/apps/login/src/lib/session.ts @@ -0,0 +1,193 @@ +import { timestampDate } from "@zitadel/client"; +import { AuthRequest } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; +import { SAMLRequest } from "@zitadel/proto/zitadel/saml/v2/authorization_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { GetSessionResponse } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { getMostRecentCookieWithLoginname } from "./cookies"; +import { + getLoginSettings, + getSession, + listAuthenticationMethodTypes, +} from "./zitadel"; + +type LoadMostRecentSessionParams = { + serviceUrl: string; + sessionParams: { + loginName?: string; + organization?: string; + }; +}; + +export async function loadMostRecentSession({ + serviceUrl, + sessionParams, +}: LoadMostRecentSessionParams): Promise { + const recent = await getMostRecentCookieWithLoginname({ + loginName: sessionParams.loginName, + organization: sessionParams.organization, + }); + + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((resp: GetSessionResponse) => resp.session); +} + +/** + * mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.) + * to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId); + **/ +export async function isSessionValid({ + serviceUrl, + session, +}: { + serviceUrl: string; + session: Session; +}): Promise { + // session can't be checked without user + if (!session.factors?.user) { + console.warn("Session has no user"); + return false; + } + + let mfaValid = true; + + const authMethodTypes = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + + const authMethods = authMethodTypes.authMethodTypes; + if (authMethods && authMethods.includes(AuthenticationMethodType.TOTP)) { + mfaValid = !!session.factors.totp?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid totpEmail factor", + session.factors.totp?.verifiedAt, + ); + } + } else if ( + authMethods && + authMethods.includes(AuthenticationMethodType.OTP_EMAIL) + ) { + mfaValid = !!session.factors.otpEmail?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid otpEmail factor", + session.factors.otpEmail?.verifiedAt, + ); + } + } else if ( + authMethods && + authMethods.includes(AuthenticationMethodType.OTP_SMS) + ) { + mfaValid = !!session.factors.otpSms?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid otpSms factor", + session.factors.otpSms?.verifiedAt, + ); + } + } else if ( + authMethods && + authMethods.includes(AuthenticationMethodType.U2F) + ) { + mfaValid = !!session.factors.webAuthN?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid u2f factor", + session.factors.webAuthN?.verifiedAt, + ); + } + } else { + // only check settings if no auth methods are available, as this would require a setup + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: session.factors?.user?.organizationId, + }); + if (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) { + const otpEmail = session.factors.otpEmail?.verifiedAt; + const otpSms = session.factors.otpSms?.verifiedAt; + const totp = session.factors.totp?.verifiedAt; + const webAuthN = session.factors.webAuthN?.verifiedAt; + const idp = session.factors.intent?.verifiedAt; // TODO: forceMFA should not consider this as valid factor + + // must have one single check + mfaValid = !!(otpEmail || otpSms || totp || webAuthN || idp); + if (!mfaValid) { + console.warn("Session has no valid multifactor", session.factors); + } + } else { + mfaValid = true; + } + } + + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const validIDP = session?.factors?.intent?.verifiedAt; + + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate).getTime() > new Date().getTime() + : true; + + if (!stillValid) { + console.warn( + "Session is expired", + session.expirationDate + ? timestampDate(session.expirationDate).toDateString() + : "no expiration date", + ); + } + + const validChecks = !!(validPassword || validPasskey || validIDP); + + return stillValid && validChecks && mfaValid; +} + +export async function findValidSession({ + serviceUrl, + sessions, + authRequest, + samlRequest, +}: { + serviceUrl: string; + sessions: Session[]; + authRequest?: AuthRequest; + samlRequest?: SAMLRequest; +}): Promise { + const sessionsWithHint = sessions.filter((s) => { + if (authRequest && authRequest.hintUserId) { + return s.factors?.user?.id === authRequest.hintUserId; + } + if (authRequest && authRequest.loginHint) { + return s.factors?.user?.loginName === authRequest.loginHint; + } + if (samlRequest) { + // TODO: do whatever + return true; + } + return true; + }); + + if (sessionsWithHint.length === 0) { + return undefined; + } + + // sort by change date descending + sessionsWithHint.sort((a, b) => { + const dateA = a.changeDate ? timestampDate(a.changeDate).getTime() : 0; + const dateB = b.changeDate ? timestampDate(b.changeDate).getTime() : 0; + return dateB - dateA; + }); + + // return the first valid session according to settings + for (const session of sessionsWithHint) { + if (await isSessionValid({ serviceUrl, session })) { + return session; + } + } + + return undefined; +} diff --git a/login/apps/login/src/lib/verify-helper.ts b/login/apps/login/src/lib/verify-helper.ts new file mode 100644 index 0000000000..dbd9b2796b --- /dev/null +++ b/login/apps/login/src/lib/verify-helper.ts @@ -0,0 +1,289 @@ +import { timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { PasswordExpirySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; +import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import crypto from "crypto"; +import moment from "moment"; +import { cookies } from "next/headers"; +import { getFingerprintIdCookie } from "./fingerprint"; +import { getUserByID } from "./zitadel"; + +export function checkPasswordChangeRequired( + expirySettings: PasswordExpirySettings | undefined, + session: Session, + humanUser: HumanUser | undefined, + organization?: string, + requestId?: string, +) { + let isOutdated = false; + if (expirySettings?.maxAgeDays && humanUser?.passwordChanged) { + const maxAgeDays = Number(expirySettings.maxAgeDays); // Convert bigint to number + const passwordChangedDate = moment( + timestampDate(humanUser.passwordChanged), + ); + const outdatedPassword = passwordChangedDate.add(maxAgeDays, "days"); + isOutdated = moment().isAfter(outdatedPassword); + } + + if (humanUser?.passwordChangeRequired || isOutdated) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + session.factors?.user?.organizationId as string, + ); + } + + if (requestId) { + params.append("requestId", requestId); + } + + return { redirect: "/password/change?" + params }; + } +} + +export function checkEmailVerified( + session: Session, + humanUser?: HumanUser, + organization?: string, + requestId?: string, +) { + if (!humanUser?.email?.isVerified) { + const paramsVerify = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + userId: session.factors?.user?.id as string, // verify needs user id + send: "true", // we request a new email code once the page is loaded + }); + + if (organization || session.factors?.user?.organizationId) { + paramsVerify.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + if (requestId) { + paramsVerify.append("requestId", requestId); + } + + return { redirect: "/verify?" + paramsVerify }; + } +} + +export function checkEmailVerification( + session: Session, + humanUser?: HumanUser, + organization?: string, + requestId?: string, +) { + if ( + !humanUser?.email?.isVerified && + process.env.EMAIL_VERIFICATION === "true" + ) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + send: "true", // set this to true as we dont expect old email codes to be valid anymore + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/verify?` + params }; + } +} + +export async function checkMFAFactors( + serviceUrl: string, + session: Session, + loginSettings: LoginSettings | undefined, + authMethods: AuthenticationMethodType[], + organization?: string, + requestId?: string, +) { + const availableMultiFactors = authMethods?.filter( + (m: AuthenticationMethodType) => + m !== AuthenticationMethodType.PASSWORD && + m !== AuthenticationMethodType.PASSKEY, + ); + + const hasAuthenticatedWithPasskey = + session.factors?.webAuthN?.verifiedAt && + session.factors?.webAuthN?.userVerified; + + // escape further checks if user has authenticated with passkey + if (hasAuthenticatedWithPasskey) { + return; + } + + // if user has not authenticated with passkey and has only one additional mfa factor, redirect to that + if (availableMultiFactors?.length == 1) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + const factor = availableMultiFactors[0]; + // if passwordless is other method, but user selected password as alternative, perform a login + if (factor === AuthenticationMethodType.TOTP) { + return { redirect: `/otp/time-based?` + params }; + } else if (factor === AuthenticationMethodType.OTP_SMS) { + return { redirect: `/otp/sms?` + params }; + } else if (factor === AuthenticationMethodType.OTP_EMAIL) { + return { redirect: `/otp/email?` + params }; + } else if (factor === AuthenticationMethodType.U2F) { + return { redirect: `/u2f?` + params }; + } + } else if (availableMultiFactors?.length > 1) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/mfa?` + params }; + } else if ( + (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) && + !availableMultiFactors.length + ) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + force: "true", // this defines if the mfa is forced in the settings + checkAfter: "true", // this defines if the check is directly made after the setup + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + // TODO: provide a way to setup passkeys on mfa page? + return { redirect: `/mfa/set?` + params }; + } else if ( + loginSettings?.mfaInitSkipLifetime && + (loginSettings.mfaInitSkipLifetime.nanos > 0 || + loginSettings.mfaInitSkipLifetime.seconds > 0) && + !availableMultiFactors.length && + session?.factors?.user?.id + ) { + const userResponse = await getUserByID({ + serviceUrl, + userId: session.factors?.user?.id, + }); + + const humanUser = + userResponse?.user?.type.case === "human" + ? userResponse?.user.type.value + : undefined; + + if (humanUser?.mfaInitSkipped) { + const mfaInitSkippedTimestamp = timestampDate(humanUser.mfaInitSkipped); + + const mfaInitSkipLifetimeMillis = + Number(loginSettings.mfaInitSkipLifetime.seconds) * 1000 + + loginSettings.mfaInitSkipLifetime.nanos / 1000000; + const currentTime = Date.now(); + const mfaInitSkippedTime = mfaInitSkippedTimestamp.getTime(); + const timeDifference = currentTime - mfaInitSkippedTime; + + if (!(timeDifference > mfaInitSkipLifetimeMillis)) { + // if the time difference is smaller than the lifetime, skip the mfa setup + return; + } + } + + // the user has never skipped the mfa init but we have a setting so we redirect + + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + force: "false", // this defines if the mfa is not forced in the settings and can be skipped + checkAfter: "true", // this defines if the check is directly made after the setup + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + // TODO: provide a way to setup passkeys on mfa page? + return { redirect: `/mfa/set?` + params }; + } +} + +export async function checkUserVerification(userId: string): Promise { + // check if a verification was done earlier + const cookiesList = await cookies(); + + // only read cookie to prevent issues on page.tsx + const fingerPrintCookie = await getFingerprintIdCookie(); + + if (!fingerPrintCookie || !fingerPrintCookie.value) { + return false; + } + + const verificationCheck = crypto + .createHash("sha256") + .update(`${userId}:${fingerPrintCookie.value}`) + .digest("hex"); + + const cookieValue = await cookiesList.get("verificationCheck")?.value; + + if (!cookieValue) { + console.warn( + "User verification check cookie not found. User verification check failed.", + ); + return false; + } + + if (cookieValue !== verificationCheck) { + console.warn( + `User verification check failed. Expected ${verificationCheck} but got ${cookieValue}`, + ); + return false; + } + + return true; +} diff --git a/login/apps/login/src/lib/zitadel.ts b/login/apps/login/src/lib/zitadel.ts new file mode 100644 index 0000000000..d8b4e5fb51 --- /dev/null +++ b/login/apps/login/src/lib/zitadel.ts @@ -0,0 +1,1523 @@ +import { Client, create, Duration } from "@zitadel/client"; +import { createServerTransport as libCreateServerTransport } from "@zitadel/client/node"; +import { makeReqCtx } from "@zitadel/client/v2"; +import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; +import { + OrganizationSchema, + TextQueryMethod, +} from "@zitadel/proto/zitadel/object/v2/object_pb"; +import { + CreateCallbackRequest, + OIDCService, +} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb"; +import { + CreateResponseRequest, + SAMLService, +} from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { + Checks, + SessionService, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; +import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb"; +import type { + FormData, + RedirectURLsJson, +} from "@zitadel/proto/zitadel/user/v2/idp_pb"; +import { + NotificationType, + SendPasswordResetLinkSchema, +} from "@zitadel/proto/zitadel/user/v2/password_pb"; +import { + SearchQuery, + SearchQuerySchema, +} from "@zitadel/proto/zitadel/user/v2/query_pb"; +import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { + AddHumanUserRequest, + AddHumanUserRequestSchema, + ResendEmailCodeRequest, + ResendEmailCodeRequestSchema, + SendEmailCodeRequestSchema, + SetPasswordRequest, + SetPasswordRequestSchema, + UpdateHumanUserRequest, + UserService, + VerifyPasskeyRegistrationRequest, + VerifyU2FRegistrationRequest, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { unstable_cacheLife as cacheLife } from "next/cache"; +import { getUserAgent } from "./fingerprint"; +import { setSAMLFormCookie } from "./saml"; +import { createServiceForHost } from "./service"; + +const useCache = process.env.DEBUG !== "true"; + +async function cacheWrapper(callback: Promise) { + "use cache"; + cacheLife("hours"); + + return callback; +} + +export async function getHostedLoginTranslation({ + serviceUrl, + organization, + locale, +}: { + serviceUrl: string; + organization?: string; + locale?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getHostedLoginTranslation( + { + level: organization + ? { + case: "organizationId", + value: organization, + } + : { + case: "instance", + value: true, + }, + locale: locale, + }, + {}, + ) + .then((resp) => { + return resp.translations ? resp.translations : undefined; + }); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getBrandingSettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getBrandingSettings({ ctx: makeReqCtx(organization) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getLoginSettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getLoginSettings({ ctx: makeReqCtx(organization) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getSecuritySettings({ + serviceUrl, +}: { + serviceUrl: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getSecuritySettings({}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getLockoutSettings({ + serviceUrl, + orgId, +}: { + serviceUrl: string; + orgId?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getLockoutSettings({ ctx: makeReqCtx(orgId) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getPasswordExpirySettings({ + serviceUrl, + orgId, +}: { + serviceUrl: string; + orgId?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getPasswordExpirySettings({ ctx: makeReqCtx(orgId) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function listIDPLinks({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.listIDPLinks({ userId }, {}); +} + +export async function addOTPEmail({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addOTPEmail({ userId }, {}); +} + +export async function addOTPSMS({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addOTPSMS({ userId }, {}); +} + +export async function registerTOTP({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.registerTOTP({ userId }, {}); +} + +export async function getGeneralSettings({ + serviceUrl, +}: { + serviceUrl: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getGeneralSettings({}, {}) + .then((resp) => resp.supportedLanguages); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getLegalAndSupportSettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getLegalAndSupportSettings({ ctx: makeReqCtx(organization) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getPasswordComplexitySettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getPasswordComplexitySettings({ ctx: makeReqCtx(organization) }) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function createSessionFromChecks({ + serviceUrl, + checks, + lifetime, +}: { + serviceUrl: string; + checks: Checks; + lifetime?: Duration; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + const userAgent = await getUserAgent(); + + return sessionService.createSession({ checks, lifetime, userAgent }, {}); +} + +export async function createSessionForUserIdAndIdpIntent({ + serviceUrl, + userId, + idpIntent, + lifetime, +}: { + serviceUrl: string; + userId: string; + idpIntent: { + idpIntentId?: string | undefined; + idpIntentToken?: string | undefined; + }; + lifetime?: Duration; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + const userAgent = await getUserAgent(); + + return sessionService.createSession({ + checks: { + user: { + search: { + case: "userId", + value: userId, + }, + }, + idpIntent, + }, + lifetime, + userAgent, + }); +} + +export async function setSession({ + serviceUrl, + sessionId, + sessionToken, + challenges, + checks, + lifetime, +}: { + serviceUrl: string; + sessionId: string; + sessionToken: string; + challenges: RequestChallenges | undefined; + checks?: Checks; + lifetime?: Duration; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.setSession( + { + sessionId, + sessionToken, + challenges, + checks: checks ? checks : {}, + metadata: {}, + lifetime, + }, + {}, + ); +} + +export async function getSession({ + serviceUrl, + sessionId, + sessionToken, +}: { + serviceUrl: string; + sessionId: string; + sessionToken: string; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.getSession({ sessionId, sessionToken }, {}); +} + +export async function deleteSession({ + serviceUrl, + sessionId, + sessionToken, +}: { + serviceUrl: string; + sessionId: string; + sessionToken: string; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.deleteSession({ sessionId, sessionToken }, {}); +} + +type ListSessionsCommand = { + serviceUrl: string; + ids: string[]; +}; + +export async function listSessions({ serviceUrl, ids }: ListSessionsCommand) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.listSessions( + { + queries: [ + { + query: { + case: "idsQuery", + value: { ids }, + }, + }, + ], + }, + {}, + ); +} + +export type AddHumanUserData = { + serviceUrl: string; + firstName: string; + lastName: string; + email: string; + password?: string; + organization: string; +}; + +export async function addHumanUser({ + serviceUrl, + email, + firstName, + lastName, + password, + organization, +}: AddHumanUserData) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + let addHumanUserRequest: AddHumanUserRequest = create( + AddHumanUserRequestSchema, + { + email: { + email, + verification: { + case: "isVerified", + value: false, + }, + }, + username: email, + profile: { givenName: firstName, familyName: lastName }, + passwordType: password + ? { case: "password", value: { password } } + : undefined, + }, + ); + + if (organization) { + const organizationSchema = create(OrganizationSchema, { + org: { case: "orgId", value: organization }, + }); + + addHumanUserRequest = { + ...addHumanUserRequest, + organization: organizationSchema, + }; + } + + return userService.addHumanUser(addHumanUserRequest); +} + +export async function addHuman({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: AddHumanUserRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addHumanUser(request); +} + +export async function updateHuman({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: UpdateHumanUserRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.updateHumanUser(request); +} + +export async function verifyTOTPRegistration({ + serviceUrl, + code, + userId, +}: { + serviceUrl: string; + code: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyTOTPRegistration({ code, userId }, {}); +} + +export async function getUserByID({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.getUserByID({ userId }, {}); +} + +export async function humanMFAInitSkipped({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.humanMFAInitSkipped({ userId }, {}); +} + +export async function verifyInviteCode({ + serviceUrl, + userId, + verificationCode, +}: { + serviceUrl: string; + userId: string; + verificationCode: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyInviteCode({ userId, verificationCode }, {}); +} + +export async function sendEmailCode({ + serviceUrl, + userId, + urlTemplate, +}: { + serviceUrl: string; + userId: string; + urlTemplate: string; +}) { + let medium = create(SendEmailCodeRequestSchema, { userId }); + + medium = create(SendEmailCodeRequestSchema, { + ...medium, + verification: { + case: "sendCode", + value: create(SendEmailVerificationCodeSchema, { + urlTemplate, + }), + }, + }); + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.sendEmailCode(medium, {}); +} + +export async function createInviteCode({ + serviceUrl, + urlTemplate, + userId, +}: { + serviceUrl: string; + urlTemplate: string; + userId: string; +}) { + let medium = create(SendInviteCodeSchema, { + applicationName: "Typescript Login", + }); + + medium = { + ...medium, + urlTemplate, + }; + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.createInviteCode( + { + userId, + verification: { + case: "sendCode", + value: medium, + }, + }, + {}, + ); +} + +export type ListUsersCommand = { + serviceUrl: string; + loginName?: string; + userName?: string; + email?: string; + phone?: string; + organizationId?: string; +}; + +export async function listUsers({ + serviceUrl, + loginName, + userName, + phone, + email, + organizationId, +}: ListUsersCommand) { + const queries: SearchQuery[] = []; + + // either use loginName or userName, email, phone + if (loginName) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "loginNameQuery", + value: { + loginName, + method: TextQueryMethod.EQUALS, + }, + }, + }), + ); + } else if (userName || email || phone) { + const orQueries: SearchQuery[] = []; + + if (userName) { + const userNameQuery = create(SearchQuerySchema, { + query: { + case: "userNameQuery", + value: { + userName, + method: TextQueryMethod.EQUALS, + }, + }, + }); + orQueries.push(userNameQuery); + } + + if (email) { + const emailQuery = create(SearchQuerySchema, { + query: { + case: "emailQuery", + value: { + emailAddress: email, + method: TextQueryMethod.EQUALS, + }, + }, + }); + orQueries.push(emailQuery); + } + + if (phone) { + const phoneQuery = create(SearchQuerySchema, { + query: { + case: "phoneQuery", + value: { + number: phone, + method: TextQueryMethod.EQUALS, + }, + }, + }); + orQueries.push(phoneQuery); + } + + queries.push( + create(SearchQuerySchema, { + query: { + case: "orQuery", + value: { + queries: orQueries, + }, + }, + }), + ); + } + + if (organizationId) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "organizationIdQuery", + value: { + organizationId, + }, + }, + }), + ); + } + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.listUsers({ queries }); +} + +export type SearchUsersCommand = { + serviceUrl: string; + searchValue: string; + loginSettings: LoginSettings; + organizationId?: string; + suffix?: string; +}; + +const PhoneQuery = (searchValue: string) => + create(SearchQuerySchema, { + query: { + case: "phoneQuery", + value: { + number: searchValue, + method: TextQueryMethod.EQUALS, + }, + }, + }); + +const LoginNameQuery = (searchValue: string) => + create(SearchQuerySchema, { + query: { + case: "loginNameQuery", + value: { + loginName: searchValue, + method: TextQueryMethod.EQUALS, + }, + }, + }); + +const EmailQuery = (searchValue: string) => + create(SearchQuerySchema, { + query: { + case: "emailQuery", + value: { + emailAddress: searchValue, + method: TextQueryMethod.EQUALS, + }, + }, + }); + +/** + * this is a dedicated search function to search for users from the loginname page + * it searches users based on the loginName or userName and org suffix combination, and falls back to email and phone if no users are found + * */ +export async function searchUsers({ + serviceUrl, + searchValue, + loginSettings, + organizationId, + suffix, +}: SearchUsersCommand) { + const queries: SearchQuery[] = []; + + // if a suffix is provided, we search for the userName concatenated with the suffix + if (suffix) { + const searchValueWithSuffix = `${searchValue}@${suffix}`; + const loginNameQuery = LoginNameQuery(searchValueWithSuffix); + queries.push(loginNameQuery); + } else { + const loginNameQuery = LoginNameQuery(searchValue); + queries.push(loginNameQuery); + } + + if (organizationId) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "organizationIdQuery", + value: { + organizationId, + }, + }, + }), + ); + } + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + const loginNameResult = await userService.listUsers({ queries }); + + if (!loginNameResult || !loginNameResult.details) { + return { error: "An error occurred." }; + } + + if (loginNameResult.result.length > 1) { + return { error: "Multiple users found" }; + } + + if (loginNameResult.result.length == 1) { + return loginNameResult; + } + + const emailAndPhoneQueries: SearchQuery[] = []; + if ( + loginSettings.disableLoginWithEmail && + loginSettings.disableLoginWithPhone + ) { + return { error: "User not found in the system" }; + } else if (loginSettings.disableLoginWithEmail && searchValue.length <= 20) { + const phoneQuery = PhoneQuery(searchValue); + emailAndPhoneQueries.push(phoneQuery); + } else if (loginSettings.disableLoginWithPhone) { + const emailQuery = EmailQuery(searchValue); + emailAndPhoneQueries.push(emailQuery); + } else { + const orQuery: SearchQuery[] = []; + + const emailQuery = EmailQuery(searchValue); + orQuery.push(emailQuery); + + let phoneQuery; + if (searchValue.length <= 20) { + phoneQuery = PhoneQuery(searchValue); + orQuery.push(phoneQuery); + } + + emailAndPhoneQueries.push( + create(SearchQuerySchema, { + query: { + case: "orQuery", + value: { + queries: orQuery, + }, + }, + }), + ); + } + + if (organizationId) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "organizationIdQuery", + value: { + organizationId, + }, + }, + }), + ); + } + + const emailOrPhoneResult = await userService.listUsers({ + queries: emailAndPhoneQueries, + }); + + if (!emailOrPhoneResult || !emailOrPhoneResult.details) { + return { error: "An error occurred." }; + } + + if (emailOrPhoneResult.result.length > 1) { + return { error: "Multiple users found." }; + } + + if (emailOrPhoneResult.result.length == 1) { + return emailOrPhoneResult; + } + + return { error: "User not found in the system" }; +} + +export async function getDefaultOrg({ + serviceUrl, +}: { + serviceUrl: string; +}): Promise { + const orgService: Client = + await createServiceForHost(OrganizationService, serviceUrl); + + return orgService + .listOrganizations( + { + queries: [ + { + query: { + case: "defaultQuery", + value: {}, + }, + }, + ], + }, + {}, + ) + .then((resp) => (resp?.result && resp.result[0] ? resp.result[0] : null)); +} + +export async function getOrgsByDomain({ + serviceUrl, + domain, +}: { + serviceUrl: string; + domain: string; +}) { + const orgService: Client = + await createServiceForHost(OrganizationService, serviceUrl); + + return orgService.listOrganizations( + { + queries: [ + { + query: { + case: "domainQuery", + value: { domain, method: TextQueryMethod.EQUALS }, + }, + }, + ], + }, + {}, + ); +} + +export async function startIdentityProviderFlow({ + serviceUrl, + idpId, + urls, +}: { + serviceUrl: string; + idpId: string; + urls: RedirectURLsJson; +}): Promise { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService + .startIdentityProviderIntent({ + idpId, + content: { + case: "urls", + value: urls, + }, + }) + .then(async (resp) => { + if (resp.nextStep.case === "authUrl" && resp.nextStep.value) { + return resp.nextStep.value; + } else if (resp.nextStep.case === "formData" && resp.nextStep.value) { + const formData: FormData = resp.nextStep.value; + const redirectUrl = "/saml-post"; + + const dataId = await setSAMLFormCookie(JSON.stringify(formData.fields)); + const params = new URLSearchParams({ url: formData.url, id: dataId }); + + return `${redirectUrl}?${params.toString()}`; + } else { + return null; + } + }); +} + +export async function startLDAPIdentityProviderFlow({ + serviceUrl, + idpId, + username, + password, +}: { + serviceUrl: string; + idpId: string; + username: string; + password: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.startIdentityProviderIntent({ + idpId, + content: { + case: "ldap", + value: { + username, + password, + }, + }, + }); +} + +export async function getAuthRequest({ + serviceUrl, + authRequestId, +}: { + serviceUrl: string; + authRequestId: string; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.getAuthRequest({ + authRequestId, + }); +} + +export async function getDeviceAuthorizationRequest({ + serviceUrl, + userCode, +}: { + serviceUrl: string; + userCode: string; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.getDeviceAuthorizationRequest({ + userCode, + }); +} + +export async function authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId, + session, +}: { + serviceUrl: string; + deviceAuthorizationId: string; + session?: { sessionId: string; sessionToken: string }; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.authorizeOrDenyDeviceAuthorization({ + deviceAuthorizationId, + decision: session + ? { + case: "session", + value: session, + } + : { + case: "deny", + value: {}, + }, + }); +} + +export async function createCallback({ + serviceUrl, + req, +}: { + serviceUrl: string; + req: CreateCallbackRequest; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.createCallback(req); +} + +export async function getSAMLRequest({ + serviceUrl, + samlRequestId, +}: { + serviceUrl: string; + samlRequestId: string; +}) { + const samlService = await createServiceForHost(SAMLService, serviceUrl); + + return samlService.getSAMLRequest({ + samlRequestId, + }); +} + +export async function createResponse({ + serviceUrl, + req, +}: { + serviceUrl: string; + req: CreateResponseRequest; +}) { + const samlService = await createServiceForHost(SAMLService, serviceUrl); + + return samlService.createResponse(req); +} + +export async function verifyEmail({ + serviceUrl, + userId, + verificationCode, +}: { + serviceUrl: string; + userId: string; + verificationCode: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyEmail( + { + userId, + verificationCode, + }, + {}, + ); +} + +export async function resendEmailCode({ + serviceUrl, + userId, + urlTemplate, +}: { + serviceUrl: string; + userId: string; + urlTemplate: string; +}) { + let request: ResendEmailCodeRequest = create(ResendEmailCodeRequestSchema, { + userId, + }); + + const medium = create(SendEmailVerificationCodeSchema, { + urlTemplate, + }); + + request = { ...request, verification: { case: "sendCode", value: medium } }; + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.resendEmailCode(request, {}); +} + +export async function retrieveIDPIntent({ + serviceUrl, + id, + token, +}: { + serviceUrl: string; + id: string; + token: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.retrieveIdentityProviderIntent( + { idpIntentId: id, idpIntentToken: token }, + {}, + ); +} + +export async function getIDPByID({ + serviceUrl, + id, +}: { + serviceUrl: string; + id: string; +}) { + const idpService: Client = + await createServiceForHost(IdentityProviderService, serviceUrl); + + return idpService.getIDPByID({ id }, {}).then((resp) => resp.idp); +} + +export async function addIDPLink({ + serviceUrl, + idp, + userId, +}: { + serviceUrl: string; + idp: { id: string; userId: string; userName: string }; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addIDPLink( + { + idpLink: { + userId: idp.userId, + idpId: idp.id, + userName: idp.userName, + }, + userId, + }, + {}, + ); +} + +export async function passwordReset({ + serviceUrl, + userId, + urlTemplate, +}: { + serviceUrl: string; + userId: string; + urlTemplate?: string; +}) { + let medium = create(SendPasswordResetLinkSchema, { + notificationType: NotificationType.Email, + }); + + medium = { + ...medium, + urlTemplate, + }; + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.passwordReset( + { + userId, + medium: { + case: "sendLink", + value: medium, + }, + }, + {}, + ); +} + +export async function setUserPassword({ + serviceUrl, + userId, + password, + code, +}: { + serviceUrl: string; + userId: string; + password: string; + code?: string; +}) { + let payload = create(SetPasswordRequestSchema, { + userId, + newPassword: { + password, + }, + }); + + if (code) { + payload = { + ...payload, + verification: { + case: "verificationCode", + value: code, + }, + }; + } + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.setPassword(payload, {}).catch((error) => { + // throw error if failed precondition (ex. User is not yet initialized) + if (error.code === 9 && error.message) { + return { error: error.message }; + } else { + throw error; + } + }); +} + +export async function setPassword({ + serviceUrl, + payload, +}: { + serviceUrl: string; + payload: SetPasswordRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.setPassword(payload, {}); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @returns the newly set email + */ +export async function createPasskeyRegistrationLink({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.createPasskeyRegistrationLink({ + userId, + medium: { + case: "returnCode", + value: {}, + }, + }); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @param domain the domain on which the factor is registered + * @returns the newly set email + */ +export async function registerU2F({ + serviceUrl, + userId, + domain, +}: { + serviceUrl: string; + userId: string; + domain: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.registerU2F({ + userId, + domain, + }); +} + +/** + * + * @param host + * @param request the request object for verifying U2F registration + * @returns the result of the verification + */ +export async function verifyU2FRegistration({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: VerifyU2FRegistrationRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyU2FRegistration(request, {}); +} + +/** + * + * @param host + * @param orgId the organization ID + * @param linking_allowed whether linking is allowed + * @returns the active identity providers + */ +export async function getActiveIdentityProviders({ + serviceUrl, + orgId, + linking_allowed, +}: { + serviceUrl: string; + orgId?: string; + linking_allowed?: boolean; +}) { + const props: any = { ctx: makeReqCtx(orgId) }; + if (linking_allowed) { + props.linkingAllowed = linking_allowed; + } + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + return settingsService.getActiveIdentityProviders(props, {}); +} + +/** + * + * @param host + * @param request the request object for verifying passkey registration + * @returns the result of the verification + */ +export async function verifyPasskeyRegistration({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: VerifyPasskeyRegistrationRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyPasskeyRegistration(request, {}); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @param code the code for registering the passkey + * @param domain the domain on which the factor is registered + * @returns the newly set email + */ +export async function registerPasskey({ + serviceUrl, + userId, + code, + domain, +}: { + serviceUrl: string; + userId: string; + code: { id: string; code: string }; + domain: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.registerPasskey({ + userId, + code, + domain, + }); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @returns the list of authentication method types + */ +export async function listAuthenticationMethodTypes({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.listAuthenticationMethodTypes({ + userId, + }); +} + +export function createServerTransport(token: string, baseUrl: string) { + return libCreateServerTransport(token, { + baseUrl, + interceptors: !process.env.CUSTOM_REQUEST_HEADERS + ? undefined + : [ + (next) => { + return (req) => { + process.env + .CUSTOM_REQUEST_HEADERS!.split(",") + .forEach((header) => { + const kv = header.split(":"); + if (kv.length === 2) { + req.header.set(kv[0].trim(), kv[1].trim()); + } else { + console.warn(`Skipping malformed header: ${header}`); + } + }); + return next(req); + }; + }, + ], + }); +} diff --git a/login/apps/login/src/middleware.ts b/login/apps/login/src/middleware.ts new file mode 100644 index 0000000000..8eca00510e --- /dev/null +++ b/login/apps/login/src/middleware.ts @@ -0,0 +1,109 @@ +import { SecuritySettings } from "@zitadel/proto/zitadel/settings/v2/security_settings_pb"; + +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { DEFAULT_CSP } from "../constants/csp"; +import { getServiceUrlFromHeaders } from "./lib/service-url"; +export const config = { + matcher: [ + "/.well-known/:path*", + "/oauth/:path*", + "/oidc/:path*", + "/idps/callback/:path*", + "/saml/:path*", + "/:path*", + ], +}; + +async function loadSecuritySettings( + request: NextRequest, +): Promise { + const securityResponse = await fetch(`${request.nextUrl.origin}/security`); + + if (!securityResponse.ok) { + console.error( + "Failed to fetch security settings:", + securityResponse.statusText, + ); + return null; + } + + const response = await securityResponse.json(); + + if (!response || !response.settings) { + console.error("No security settings found in the response."); + return null; + } + + return response.settings; +} + +export async function middleware(request: NextRequest) { + // Add the original URL as a header to all requests + const requestHeaders = new Headers(request.headers); + + // Extract "organization" search param from the URL and set it as a header if available + const organization = request.nextUrl.searchParams.get("organization"); + if (organization) { + requestHeaders.set("x-zitadel-i18n-organization", organization); + } + + // Only run the rest of the logic for the original matcher paths + const proxyPaths = [ + "/.well-known/", + "/oauth/", + "/oidc/", + "/idps/callback/", + "/saml/", + ]; + + const isMatched = proxyPaths.some((prefix) => + request.nextUrl.pathname.startsWith(prefix), + ); + + // escape proxy if the environment is setup for multitenancy + if ( + !isMatched || + !process.env.ZITADEL_API_URL || + !process.env.ZITADEL_SERVICE_USER_TOKEN + ) { + // For all other routes, just add the header and continue + return NextResponse.next({ + request: { headers: requestHeaders }, + }); + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const instanceHost = `${serviceUrl}` + .replace("https://", "") + .replace("http://", ""); + + // Add additional headers as before + requestHeaders.set("x-zitadel-public-host", `${request.nextUrl.host}`); + requestHeaders.set("x-zitadel-instance-host", instanceHost); + + const responseHeaders = new Headers(); + responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.set("Access-Control-Allow-Headers", "*"); + + const securitySettings = await loadSecuritySettings(request); + + if (securitySettings?.embeddedIframe?.enabled) { + responseHeaders.set( + "Content-Security-Policy", + `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, + ); + responseHeaders.delete("X-Frame-Options"); + } + + request.nextUrl.href = `${serviceUrl}${request.nextUrl.pathname}${request.nextUrl.search}`; + + return NextResponse.rewrite(request.nextUrl, { + request: { + headers: requestHeaders, + }, + headers: responseHeaders, + }); +} diff --git a/login/apps/login/src/styles/globals.scss b/login/apps/login/src/styles/globals.scss new file mode 100755 index 0000000000..cfce853bc7 --- /dev/null +++ b/login/apps/login/src/styles/globals.scss @@ -0,0 +1,65 @@ +// include styles from the ui package +@use "./vars.scss"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + h1, + .ztdl-h1 { + @apply text-2xl text-center; + } + + .ztdl-p { + @apply text-sm text-center text-text-light-secondary-500 dark:text-text-dark-secondary-500 text-center; + } +} + +html { + --background-color: #ffffff; + --dark-background-color: #000000; +} + +.form-checkbox:checked { + background-image: url("/checkbox.svg"); +} + +.skeleton { + --accents-2: var(--theme-light-background-400); + --accents-1: var(--theme-light-background-500); + + background-image: linear-gradient( + 270deg, + var(--accents-1), + var(--accents-2), + var(--accents-2), + var(--accents-1) + ); + background-size: 400% 100%; + animation: skeleton_loading 8s ease-in-out infinite; +} + +.dark .skeleton { + --accents-2: var(--theme-dark-background-400); + --accents-1: var(--theme-dark-background-500); + + background-image: linear-gradient( + 270deg, + var(--accents-1), + var(--accents-2), + var(--accents-2), + var(--accents-1) + ); + background-size: 400% 100%; + animation: skeleton_loading 8s ease-in-out infinite; +} + +@keyframes skeleton_loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/login/apps/login/src/styles/vars.scss b/login/apps/login/src/styles/vars.scss new file mode 100644 index 0000000000..71c6a28782 --- /dev/null +++ b/login/apps/login/src/styles/vars.scss @@ -0,0 +1,174 @@ +:root { + --theme-dark-primary-50: #f1f7fd; + --theme-dark-primary-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-100: #afd1f2; + --theme-dark-primary-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-200: #7fb5ea; + --theme-dark-primary-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-300: #4192e0; + --theme-dark-primary-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-400: #2782dc; + --theme-dark-primary-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-500: #2073c4; + --theme-dark-primary-contrast-500: #ffffff; + --theme-dark-primary-600: #1c64aa; + --theme-dark-primary-contrast-600: #ffffff; + --theme-dark-primary-700: #17548f; + --theme-dark-primary-contrast-700: #ffffff; + --theme-dark-primary-800: #134575; + --theme-dark-primary-contrast-800: #ffffff; + --theme-dark-primary-900: #0f355b; + --theme-dark-primary-contrast-900: #ffffff; + --theme-dark-primary-A100: #e4f2ff; + --theme-dark-primary-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-A200: #7ebfff; + --theme-dark-primary-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-A400: #278df0; + --theme-dark-primary-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-A700: #1d80e0; + --theme-dark-primary-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-50: #ffffff; + --theme-light-primary-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-100: #ebedfa; + --theme-light-primary-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-200: #bec6ef; + --theme-light-primary-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-300: #8594e0; + --theme-light-primary-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-400: #6c7eda; + --theme-light-primary-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-500: #5469d4; + --theme-light-primary-contrast-500: #ffffff; + --theme-light-primary-600: #3c54ce; + --theme-light-primary-contrast-600: #ffffff; + --theme-light-primary-700: #2f46bc; + --theme-light-primary-contrast-700: #ffffff; + --theme-light-primary-800: #293da3; + --theme-light-primary-contrast-800: #ffffff; + --theme-light-primary-900: #23348b; + --theme-light-primary-contrast-900: #ffffff; + --theme-light-primary-A100: #ffffff; + --theme-light-primary-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-A200: #c5cefc; + --theme-light-primary-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-A400: #7085ea; + --theme-light-primary-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-A700: #6478de; + --theme-light-primary-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-50: #ffffff; + --theme-dark-warn-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-100: #fff8f9; + --theme-dark-warn-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-200: #ffc0ca; + --theme-dark-warn-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-300: #ff788e; + --theme-dark-warn-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-400: #ff5a75; + --theme-dark-warn-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-500: #ff3b5b; + --theme-dark-warn-contrast-500: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-600: #ff1c41; + --theme-dark-warn-contrast-600: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-700: #fd0029; + --theme-dark-warn-contrast-700: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-800: #de0024; + --theme-dark-warn-contrast-800: #ffffff; + --theme-dark-warn-900: #c0001f; + --theme-dark-warn-contrast-900: #ffffff; + --theme-dark-warn-A100: #ffffff; + --theme-dark-warn-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-A200: #ffd4db; + --theme-dark-warn-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-A400: #ff6e86; + --theme-dark-warn-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-A700: #ff5470; + --theme-dark-warn-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-50: #ffffff; + --theme-light-warn-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-100: #f4d3d9; + --theme-light-warn-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-200: #e8a6b2; + --theme-light-warn-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-300: #da6e80; + --theme-light-warn-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-400: #d3556b; + --theme-light-warn-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-500: #cd3d56; + --theme-light-warn-contrast-500: #ffffff; + --theme-light-warn-600: #bb3048; + --theme-light-warn-contrast-600: #ffffff; + --theme-light-warn-700: #a32a3f; + --theme-light-warn-contrast-700: #ffffff; + --theme-light-warn-800: #8a2436; + --theme-light-warn-contrast-800: #ffffff; + --theme-light-warn-900: #721d2c; + --theme-light-warn-contrast-900: #ffffff; + --theme-light-warn-A100: #ffffff; + --theme-light-warn-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-A200: #faa9b7; + --theme-light-warn-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-A400: #e65770; + --theme-light-warn-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-A700: #d84c64; + --theme-light-warn-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-dark-background-50: #7c93c6; + --theme-dark-background-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-dark-background-100: #4a69aa; + --theme-dark-background-contrast-100: #ffffff; + --theme-dark-background-200: #395183; + --theme-dark-background-contrast-200: #ffffff; + --theme-dark-background-300: #243252; + --theme-dark-background-contrast-300: #ffffff; + --theme-dark-background-400: #1a253c; + --theme-dark-background-contrast-400: #ffffff; + --theme-dark-background-500: #111827; + --theme-dark-background-contrast-500: #ffffff; + --theme-dark-background-600: #080b12; + --theme-dark-background-contrast-600: #ffffff; + --theme-dark-background-700: #000000; + --theme-dark-background-contrast-700: #ffffff; + --theme-dark-background-800: #000000; + --theme-dark-background-contrast-800: #ffffff; + --theme-dark-background-900: #000000; + --theme-dark-background-contrast-900: #ffffff; + --theme-dark-background-A100: #5782e0; + --theme-dark-background-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-dark-background-A200: #204eb1; + --theme-dark-background-contrast-A200: #ffffff; + --theme-dark-background-A400: #182b53; + --theme-dark-background-contrast-A400: #ffffff; + --theme-dark-background-A700: #17223b; + --theme-dark-background-contrast-A700: #ffffff; + --theme-light-background-50: #ffffff; + --theme-light-background-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-light-background-100: #ffffff; + --theme-light-background-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-light-background-200: #ffffff; + --theme-light-background-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-light-background-300: #ffffff; + --theme-light-background-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-light-background-400: #ffffff; + --theme-light-background-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-light-background-500: #fafafa; + --theme-light-background-contrast-500: hsla(0, 0%, 0%, 0.87); + --theme-light-background-600: #ebebeb; + --theme-light-background-contrast-600: hsla(0, 0%, 0%, 0.87); + --theme-light-background-700: #dbdbdb; + --theme-light-background-contrast-700: hsla(0, 0%, 0%, 0.87); + --theme-light-background-800: #cccccc; + --theme-light-background-contrast-800: hsla(0, 0%, 0%, 0.87); + --theme-light-background-900: #bdbdbd; + --theme-light-background-contrast-900: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A100: #ffffff; + --theme-light-background-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A200: #ffffff; + --theme-light-background-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A400: #ffffff; + --theme-light-background-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A700: #ffffff; + --theme-light-background-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-dark-text: #ffffff; + --theme-dark-secondary-text: #ffffffc7; + --theme-light-text: #000000; + --theme-light-secondary-text: #000000c7; +} diff --git a/login/apps/login/tailwind.config.mjs b/login/apps/login/tailwind.config.mjs new file mode 100644 index 0000000000..908068e5dd --- /dev/null +++ b/login/apps/login/tailwind.config.mjs @@ -0,0 +1,117 @@ +import sharedConfig from "@zitadel/tailwind-config/tailwind.config.mjs"; + +let colors = { + background: { light: { contrast: {} }, dark: { contrast: {} } }, + primary: { light: { contrast: {} }, dark: { contrast: {} } }, + warn: { light: { contrast: {} }, dark: { contrast: {} } }, + text: { light: { contrast: {} }, dark: { contrast: {} } }, + link: { light: { contrast: {} }, dark: { contrast: {} } }, +}; + +const shades = [ + "50", + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", +]; +const themes = ["light", "dark"]; +const types = ["background", "primary", "warn", "text", "link"]; +types.forEach((type) => { + themes.forEach((theme) => { + shades.forEach((shade) => { + colors[type][theme][shade] = `var(--theme-${theme}-${type}-${shade})`; + colors[type][theme][`contrast-${shade}`] = + `var(--theme-${theme}-${type}-contrast-${shade})`; + colors[type][theme][`secondary-${shade}`] = + `var(--theme-${theme}-${type}-secondary-${shade})`; + }); + }); +}); + +/** @type {import('tailwindcss').Config} */ +export default { + presets: [sharedConfig], + darkMode: "class", + content: ["./src/**/*.{js,ts,jsx,tsx}"], + future: { + hoverOnlyWhenSupported: true, + }, + theme: { + extend: { + colors: { + ...colors, + state: { + success: { + light: { + background: "#cbf4c9", + color: "#0e6245", + }, + dark: { + background: "#68cf8340", + color: "#cbf4c9", + }, + }, + error: { + light: { + background: "#ffc1c1", + color: "#620e0e", + }, + dark: { + background: "#af455359", + color: "#ffc1c1", + }, + }, + neutral: { + light: { + background: "#e4e7e4", + color: "#000000", + }, + dark: { + background: "#1a253c", + color: "#ffffff", + }, + }, + alert: { + light: { + background: "#fbbf24", + color: "#92400e", + }, + dark: { + background: "#92400e50", + color: "#fbbf24", + }, + }, + }, + }, + animation: { + shake: "shake .8s cubic-bezier(.36,.07,.19,.97) both;", + }, + keyframes: { + shake: { + "10%, 90%": { + transform: "translate3d(-1px, 0, 0)", + }, + + "20%, 80%": { + transform: "translate3d(2px, 0, 0)", + }, + + "30%, 50%, 70%": { + transform: "translate3d(-4px, 0, 0)", + }, + + "40%, 60%": { + transform: "translate3d(4px, 0, 0)", + }, + }, + }, + }, + }, + plugins: [require("@tailwindcss/forms")], +}; diff --git a/login/apps/login/tsconfig.json b/login/apps/login/tsconfig.json new file mode 100755 index 0000000000..c855c43225 --- /dev/null +++ b/login/apps/login/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "@zitadel/tsconfig/nextjs.json", + "compilerOptions": { + "jsx": "preserve", + "target": "es2022", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "custom-config.js" + ], + "exclude": ["node_modules"] +} diff --git a/login/apps/login/turbo.json b/login/apps/login/turbo.json new file mode 100644 index 0000000000..bc63a2dbc4 --- /dev/null +++ b/login/apps/login/turbo.json @@ -0,0 +1,22 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**", ".next/**", "!.next/cache/**"], + "dependsOn": ["^build"] + }, + "build:login:standalone": { + "outputs": ["dist/**", ".next/**", "!.next/cache/**"] + }, + "test": { + "dependsOn": ["@zitadel/client#build"] + }, + "test:unit": { + "dependsOn": ["@zitadel/client#build"] + }, + "test:unit:standalone": {}, + "test:watch": { + "dependsOn": ["@zitadel/client#build"] + } + } +} diff --git a/login/apps/login/vitest.config.mts b/login/apps/login/vitest.config.mts new file mode 100644 index 0000000000..238c5b8b93 --- /dev/null +++ b/login/apps/login/vitest.config.mts @@ -0,0 +1,12 @@ +import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths(), react()], + test: { + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + environment: "jsdom", + setupFiles: ["@testing-library/jest-dom/vitest"], + }, +}); diff --git a/login/docker-bake-release.hcl b/login/docker-bake-release.hcl new file mode 100644 index 0000000000..51e1c194f6 --- /dev/null +++ b/login/docker-bake-release.hcl @@ -0,0 +1,3 @@ +target "release" { + platforms = ["linux/amd64", "linux/arm64"] +} diff --git a/login/docker-bake.hcl b/login/docker-bake.hcl new file mode 100644 index 0000000000..b60fd7270a --- /dev/null +++ b/login/docker-bake.hcl @@ -0,0 +1,159 @@ +variable "LOGIN_DIR" { + default = "./" +} + +variable "DOCKERFILES_DIR" { + default = "dockerfiles/" +} + +# The release target is overwritten in docker-bake-release.hcl +# It makes sure the image is built for multiple platforms. +# By default the platforms property is empty, so images are only built for the current bake runtime platform. +target "release" {} + +# typescript-proto-client is used to generate the client code for the login service. +# It is not login-prefixed, so it is easily extendable. +# To extend this bake-file.hcl, set the context of all login-prefixed targets to a different directory. +# For example docker bake --file login/docker-bake.hcl --file docker-bake.hcl --set login-*.context=./login/ +# The zitadel repository uses this to generate the client and the mock server from local proto files. +target "typescript-proto-client" { + inherits = ["release"] + dockerfile = "${DOCKERFILES_DIR}typescript-proto-client.Dockerfile" + contexts = { + # We directly generate and download the client server-side with buf, so we don't need the proto files + login-pnpm = "target:login-pnpm" + } +} + +# We prefix the target with login- so we can reuse the writing of protos if we overwrite the typescript-proto-client target. +target "login-typescript-proto-client-out" { + dockerfile = "${DOCKERFILES_DIR}login-typescript-proto-client-out.Dockerfile" + contexts = { + typescript-proto-client = "target:typescript-proto-client" + } + output = [ + "type=local,dest=${LOGIN_DIR}packages/zitadel-proto" + ] +} + +# proto-files is only used to build core-mock against which the integration tests run. +# To build the proto-client, we use buf to generate and download the client code directly. +# It is not login-prefixed, so it is easily extendable. +# To extend this bake-file.hcl, set the context of all login-prefixed targets to a different directory. +# For example docker bake --file login/docker-bake.hcl --file docker-bake.hcl --set login-*.context=./login/ +# The zitadel repository uses this to generate the client and the mock server from local proto files. +target "proto-files" { + inherits = ["release"] + dockerfile = "${DOCKERFILES_DIR}proto-files.Dockerfile" + contexts = { + login-pnpm = "target:login-pnpm" + } +} + +variable "NODE_VERSION" { + default = "20" +} + +target "login-pnpm" { + inherits = ["release"] + dockerfile = "${DOCKERFILES_DIR}login-pnpm.Dockerfile" + args = { + NODE_VERSION = "${NODE_VERSION}" + } +} + +target "login-dev-base" { + dockerfile = "${DOCKERFILES_DIR}login-dev-base.Dockerfile" + contexts = { + login-pnpm = "target:login-pnpm" + } +} + +target "login-lint" { + dockerfile = "${DOCKERFILES_DIR}login-lint.Dockerfile" + contexts = { + login-dev-base = "target:login-dev-base" + } +} + +target "login-test-unit" { + dockerfile = "${DOCKERFILES_DIR}login-test-unit.Dockerfile" + contexts = { + login-client = "target:login-client" + } +} + +target "login-client" { + inherits = ["release"] + dockerfile = "${DOCKERFILES_DIR}login-client.Dockerfile" + contexts = { + login-pnpm = "target:login-pnpm" + typescript-proto-client = "target:typescript-proto-client" + } +} + +variable "LOGIN_CORE_MOCK_TAG" { + default = "login-core-mock:local" +} + +# the core-mock context must not be overwritten, so we don't prefix it with login-. +target "core-mock" { + context = "${LOGIN_DIR}apps/login-test-integration/core-mock" + contexts = { + protos = "target:proto-files" + } + tags = ["${LOGIN_CORE_MOCK_TAG}"] +} + +variable "LOGIN_TEST_INTEGRATION_TAG" { + default = "login-test-integration:local" +} + +target "login-test-integration" { + dockerfile = "${DOCKERFILES_DIR}login-test-integration.Dockerfile" + contexts = { + login-pnpm = "target:login-pnpm" + } + tags = ["${LOGIN_TEST_INTEGRATION_TAG}"] +} + +variable "LOGIN_TEST_ACCEPTANCE_TAG" { + default = "login-test-acceptance:local" +} + +target "login-test-acceptance" { + dockerfile = "${DOCKERFILES_DIR}login-test-acceptance.Dockerfile" + contexts = { + login-pnpm = "target:login-pnpm" + } + tags = ["${LOGIN_TEST_ACCEPTANCE_TAG}"] +} + +variable "LOGIN_TAG" { + default = "zitadel-login:local" +} + +target "docker-metadata-action" { + # In the pipeline, this target is overwritten by the docker metadata action. + tags = ["${LOGIN_TAG}"] +} + +# We run integration and acceptance tests against the next standalone server for docker. +target "login-standalone" { + inherits = [ + "docker-metadata-action", + "release", + ] + dockerfile = "${DOCKERFILES_DIR}login-standalone.Dockerfile" + contexts = { + login-client = "target:login-client" + } +} + +target "login-standalone-out" { + inherits = ["login-standalone"] + target = "login-standalone-out" + output = [ + "type=local,dest=${LOGIN_DIR}apps/login/standalone" + ] +} diff --git a/login/dockerfiles/login-client.Dockerfile b/login/dockerfiles/login-client.Dockerfile new file mode 100644 index 0000000000..4eb01615b4 --- /dev/null +++ b/login/dockerfiles/login-client.Dockerfile @@ -0,0 +1,7 @@ +FROM typescript-proto-client AS login-client +COPY packages/zitadel-tsconfig packages/zitadel-tsconfig +COPY packages/zitadel-client/package.json ./packages/zitadel-client/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter ./packages/zitadel-client +COPY packages/zitadel-client ./packages/zitadel-client +RUN pnpm build:client:standalone diff --git a/login/dockerfiles/login-client.Dockerfile.dockerignore b/login/dockerfiles/login-client.Dockerfile.dockerignore new file mode 100644 index 0000000000..c2302359f5 --- /dev/null +++ b/login/dockerfiles/login-client.Dockerfile.dockerignore @@ -0,0 +1,11 @@ +* + +!packages/zitadel-client +packages/zitadel-client/dist + +!packages/zitadel-tsconfig + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/login/dockerfiles/login-dev-base.Dockerfile b/login/dockerfiles/login-dev-base.Dockerfile new file mode 100644 index 0000000000..e102d16746 --- /dev/null +++ b/login/dockerfiles/login-dev-base.Dockerfile @@ -0,0 +1,3 @@ +FROM login-pnpm AS login-dev-base +RUN pnpm install --frozen-lockfile --prefer-offline --workspace-root --filter . + diff --git a/login/dockerfiles/login-dev-base.Dockerfile.dockerignore b/login/dockerfiles/login-dev-base.Dockerfile.dockerignore new file mode 100644 index 0000000000..72e8ffc0db --- /dev/null +++ b/login/dockerfiles/login-dev-base.Dockerfile.dockerignore @@ -0,0 +1 @@ +* diff --git a/login/dockerfiles/login-lint.Dockerfile b/login/dockerfiles/login-lint.Dockerfile new file mode 100644 index 0000000000..0c466b4cfa --- /dev/null +++ b/login/dockerfiles/login-lint.Dockerfile @@ -0,0 +1,7 @@ +FROM login-dev-base AS login-lint +COPY .prettierrc .prettierignore ./ +COPY apps/login/package.json apps/login/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter apps/login +COPY . . +RUN pnpm lint && pnpm format diff --git a/login/dockerfiles/login-lint.Dockerfile.dockerignore b/login/dockerfiles/login-lint.Dockerfile.dockerignore new file mode 100644 index 0000000000..1029f73c02 --- /dev/null +++ b/login/dockerfiles/login-lint.Dockerfile.dockerignore @@ -0,0 +1,25 @@ +* + +!apps/login +apps/login/.next +apps/login/dist +apps/login/screenshots +apps/login/standalone +apps/login/.env*.local + +!apps/login-test-integration + +!apps/login-test-acceptance +apps/login-test-acceptance/test-results + +!/packages/zitadel-tsconfig/* +!/packages/zitadel-prettier-config +!/packages/zitadel-eslint-config + +!/.prettierrc +!/.prettierignore + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/login/dockerfiles/login-pnpm.Dockerfile b/login/dockerfiles/login-pnpm.Dockerfile new file mode 100644 index 0000000000..bcef6d126c --- /dev/null +++ b/login/dockerfiles/login-pnpm.Dockerfile @@ -0,0 +1,10 @@ +ARG NODE_VERSION=20 +FROM node:${NODE_VERSION}-bookworm AS login-pnpm +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@9.1.2 --activate && \ + apt-get update && apt-get install -y --no-install-recommends && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /build +COPY turbo.json .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +ENTRYPOINT ["pnpm"] diff --git a/login/dockerfiles/login-pnpm.Dockerfile.dockerignore b/login/dockerfiles/login-pnpm.Dockerfile.dockerignore new file mode 100644 index 0000000000..067514fdd3 --- /dev/null +++ b/login/dockerfiles/login-pnpm.Dockerfile.dockerignore @@ -0,0 +1,6 @@ +* +!/turbo.json +!/.npmrc +!/package.json +!/pnpm-lock.yaml +!/pnpm-workspace.yaml diff --git a/login/dockerfiles/login-standalone.Dockerfile b/login/dockerfiles/login-standalone.Dockerfile new file mode 100644 index 0000000000..7e97d344db --- /dev/null +++ b/login/dockerfiles/login-standalone.Dockerfile @@ -0,0 +1,34 @@ +FROM login-client AS login-standalone-builder +COPY apps/login ./apps/login +COPY packages/zitadel-tailwind-config packages/zitadel-tailwind-config +RUN pnpm exec turbo prune @zitadel/login --docker +WORKDIR /build/docker +RUN cp -r ../out/json/* . +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile +RUN cp -r ../out/full/* . +RUN pnpm exec turbo run build:login:standalone + +FROM scratch AS login-standalone-out +COPY --from=login-standalone-builder /build/docker/apps/login/.next/standalone / +COPY --from=login-standalone-builder /build/docker/apps/login/.next/static /apps/login/.next/static +COPY --from=login-standalone-builder /build/docker/apps/login/public /apps/login/public + +FROM node:20-alpine AS login-standalone +WORKDIR /runtime +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs +# If /.env-file/.env is mounted into the container, its variables are made available to the server before it starts up. +RUN mkdir -p /.env-file && touch /.env-file/.env && chown -R nextjs:nodejs /.env-file +COPY ./scripts/entrypoint.sh ./ +COPY ./scripts/healthcheck.js ./ +COPY --chown=nextjs:nodejs --from=login-standalone-builder /build/docker/apps/login/.next/standalone ./ +COPY --chown=nextjs:nodejs --from=login-standalone-builder /build/docker/apps/login/.next/static ./apps/login/.next/static +COPY --chown=nextjs:nodejs --from=login-standalone-builder /build/docker/apps/login/public ./apps/login/public +USER nextjs +ENV HOSTNAME="0.0.0.0" +ENV PORT=3000 +# TODO: Check healthy, not ready +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ +CMD ["/bin/sh", "-c", "node ./healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"] +ENTRYPOINT ["./entrypoint.sh"] diff --git a/login/dockerfiles/login-standalone.Dockerfile.dockerignore b/login/dockerfiles/login-standalone.Dockerfile.dockerignore new file mode 100644 index 0000000000..f876e1e9f1 --- /dev/null +++ b/login/dockerfiles/login-standalone.Dockerfile.dockerignore @@ -0,0 +1,17 @@ +* + +!apps/login +apps/login/.next +apps/login/dist +apps/login/screenshots +apps/login/standalone +apps/login/.env*.local + +!scripts/entrypoint.sh +!scripts/healthcheck.js +!packages/zitadel-tailwind-config + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/login/dockerfiles/login-test-acceptance.Dockerfile b/login/dockerfiles/login-test-acceptance.Dockerfile new file mode 100644 index 0000000000..7052484779 --- /dev/null +++ b/login/dockerfiles/login-test-acceptance.Dockerfile @@ -0,0 +1,8 @@ +FROM login-pnpm AS login-test-acceptance-dependencies +COPY ./apps/login-test-acceptance/package.json ./apps/login-test-acceptance/package.json +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --filter=login-test-acceptance && \ + cd apps/login-test-acceptance && \ + pnpm exec playwright install --with-deps chromium +COPY ./apps/login-test-acceptance ./apps/login-test-acceptance +CMD ["bash", "-c", "cd apps/login-test-acceptance && pnpm test:acceptance test"] diff --git a/login/dockerfiles/login-test-acceptance.Dockerfile.dockerignore b/login/dockerfiles/login-test-acceptance.Dockerfile.dockerignore new file mode 100644 index 0000000000..cba55ae91e --- /dev/null +++ b/login/dockerfiles/login-test-acceptance.Dockerfile.dockerignore @@ -0,0 +1,5 @@ +* +!/apps/login-test-acceptance/*.json +!/apps/login-test-acceptance/*.ts +!/apps/login-test-acceptance/zitadel.yaml +!/apps/login-test-acceptance/tests diff --git a/login/dockerfiles/login-test-integration.Dockerfile b/login/dockerfiles/login-test-integration.Dockerfile new file mode 100644 index 0000000000..0b55dc2b1a --- /dev/null +++ b/login/dockerfiles/login-test-integration.Dockerfile @@ -0,0 +1,11 @@ +FROM login-pnpm AS login-test-integration-dependencies +COPY ./apps/login-test-integration/package.json ./apps/login-test-integration/package.json +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --filter=login-test-integration +FROM cypress/factory:5.10.0 AS login-test-integration +WORKDIR /opt/app +COPY --from=login-test-integration-dependencies /build/apps/login-test-integration . +RUN npm install cypress +RUN npx cypress install +COPY ./apps/login-test-integration . +CMD ["npx", "cypress", "run"] diff --git a/login/dockerfiles/login-test-integration.Dockerfile.dockerignore b/login/dockerfiles/login-test-integration.Dockerfile.dockerignore new file mode 100644 index 0000000000..947a4fdb57 --- /dev/null +++ b/login/dockerfiles/login-test-integration.Dockerfile.dockerignore @@ -0,0 +1,9 @@ +* + +!/apps/login-test-integration +/apps/login-test-integration/core-mock + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/login/dockerfiles/login-test-unit.Dockerfile b/login/dockerfiles/login-test-unit.Dockerfile new file mode 100644 index 0000000000..d456a4fac4 --- /dev/null +++ b/login/dockerfiles/login-test-unit.Dockerfile @@ -0,0 +1,6 @@ +FROM login-client AS login-test-unit +COPY apps/login/package.json ./apps/login/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter ./apps/login +COPY apps/login ./apps/login +RUN pnpm test:unit:standalone diff --git a/login/dockerfiles/login-test-unit.Dockerfile.dockerignore b/login/dockerfiles/login-test-unit.Dockerfile.dockerignore new file mode 100644 index 0000000000..2263653c69 --- /dev/null +++ b/login/dockerfiles/login-test-unit.Dockerfile.dockerignore @@ -0,0 +1,13 @@ +* + +!apps/login +apps/login/.next +apps/login/dist +apps/login/screenshots +apps/login/standalone +apps/login/.env*.local + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/login/dockerfiles/login-typescript-proto-client-out.Dockerfile b/login/dockerfiles/login-typescript-proto-client-out.Dockerfile new file mode 100644 index 0000000000..3aa3c9d7d6 --- /dev/null +++ b/login/dockerfiles/login-typescript-proto-client-out.Dockerfile @@ -0,0 +1,5 @@ +FROM scratch AS typescript-proto-client-out +COPY --from=typescript-proto-client /build/packages/zitadel-proto/zitadel /zitadel +COPY --from=typescript-proto-client /build/packages/zitadel-proto/google /google +COPY --from=typescript-proto-client /build/packages/zitadel-proto/protoc-gen-openapiv2 /protoc-gen-openapiv2 +COPY --from=typescript-proto-client /build/packages/zitadel-proto/validate /validate diff --git a/login/dockerfiles/login-typescript-proto-client-out.Dockerfile.dockerignore b/login/dockerfiles/login-typescript-proto-client-out.Dockerfile.dockerignore new file mode 100644 index 0000000000..72e8ffc0db --- /dev/null +++ b/login/dockerfiles/login-typescript-proto-client-out.Dockerfile.dockerignore @@ -0,0 +1 @@ +* diff --git a/login/dockerfiles/proto-files.Dockerfile b/login/dockerfiles/proto-files.Dockerfile new file mode 100644 index 0000000000..f97f63a718 --- /dev/null +++ b/login/dockerfiles/proto-files.Dockerfile @@ -0,0 +1,8 @@ +FROM bufbuild/buf:1.54.0 AS proto-files +RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto-files && \ + buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --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-files && \ + buf export https://github.com/zitadel/zitadel.git --path ./proto/zitadel --output /proto-files + +FROM scratch +COPY --from=proto-files /proto-files / diff --git a/login/dockerfiles/proto-files.Dockerfile.dockerignore b/login/dockerfiles/proto-files.Dockerfile.dockerignore new file mode 100644 index 0000000000..72e8ffc0db --- /dev/null +++ b/login/dockerfiles/proto-files.Dockerfile.dockerignore @@ -0,0 +1 @@ +* diff --git a/login/dockerfiles/typescript-proto-client.Dockerfile b/login/dockerfiles/typescript-proto-client.Dockerfile new file mode 100644 index 0000000000..ee0848f52d --- /dev/null +++ b/login/dockerfiles/typescript-proto-client.Dockerfile @@ -0,0 +1,6 @@ +FROM login-pnpm AS typescript-proto-client +COPY packages/zitadel-proto/package.json ./packages/zitadel-proto/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter zitadel-proto +COPY packages/zitadel-proto ./packages/zitadel-proto +RUN pnpm generate diff --git a/login/dockerfiles/typescript-proto-client.Dockerfile.dockerignore b/login/dockerfiles/typescript-proto-client.Dockerfile.dockerignore new file mode 100644 index 0000000000..e67848e8c3 --- /dev/null +++ b/login/dockerfiles/typescript-proto-client.Dockerfile.dockerignore @@ -0,0 +1,11 @@ +* +!/packages/zitadel-proto/ +packages/zitadel-proto/google +packages/zitadel-proto/zitadel +packages/zitadel-proto/protoc-gen-openapiv2 +packages/zitadel-proto/validate + +**/*.md +**/*.png +**/node_modules +**/.turbo \ No newline at end of file diff --git a/login/meta.json b/login/meta.json new file mode 100644 index 0000000000..2c443f76cf --- /dev/null +++ b/login/meta.json @@ -0,0 +1,4 @@ +{ + "name": "ZITADEL typescript Monorepo with Changesets", + "description": "ZITADEL typescript monorepo preconfigured to publish packages via Changesets" +} diff --git a/login/package.json b/login/package.json new file mode 100644 index 0000000000..ce844c4b2c --- /dev/null +++ b/login/package.json @@ -0,0 +1,55 @@ +{ + "packageManager": "pnpm@9.1.2+sha256.19c17528f9ca20bd442e4ca42f00f1b9808a9cb419383cd04ba32ef19322aba7", + "private": true, + "name": "typescript-monorepo", + "scripts": { + "generate": "pnpm exec turbo run generate", + "build": "pnpm exec turbo run build", + "build:client:standalone": "pnpm exec turbo run build:client:standalone", + "build:login:standalone": "pnpm exec turbo run build:login:standalone", + "build:packages": "pnpm exec turbo run build --filter=./packages/*", + "build:apps": "pnpm exec turbo run build --filter=./apps/*", + "test": "pnpm exec turbo run test", + "start": "pnpm exec turbo run start", + "start:built": "pnpm exec turbo run start:built", + "test:unit": "pnpm exec turbo run test:unit -- --passWithNoTests", + "test:unit:standalone": "pnpm exec turbo run test:unit:standalone -- --passWithNoTests", + "test:integration": "cd apps/login-test-integration && pnpm test:integration", + "test:integration:setup": "NODE_ENV=test pnpm exec turbo run test:integration:setup", + "test:acceptance": "cd apps/login-test-acceptance && pnpm test:acceptance", + "test:acceptance:setup": "cd apps/login-test-acceptance && pnpm test:acceptance:setup", + "test:watch": "pnpm exec turbo run test:watch", + "dev": "pnpm exec turbo run dev --no-cache --continue", + "dev:local": "pnpm test:acceptance:setup", + "lint": "pnpm exec turbo run lint", + "lint:fix": "pnpm exec turbo run lint:fix", + "clean": "pnpm exec turbo run clean && rm -rf node_modules", + "format:fix": "pnpm exec prettier --write \"**/*.{ts,tsx,md}\"", + "format": "pnpm exec prettier --check \"**/*.{ts,tsx,md}\"", + "changeset": "pnpm exec changeset", + "version-packages": "pnpm exec changeset version", + "release": "pnpm exec turbo run build --filter=login^... && pnpm exec changeset publish" + }, + "pnpm": { + "overrides": { + "@typescript-eslint/parser": "^7.9.0" + } + }, + "devDependencies": { + "@changesets/cli": "^2.29.2", + "@vitejs/plugin-react": "^4.4.1", + "@zitadel/eslint-config": "workspace:*", + "@zitadel/prettier-config": "workspace:*", + "axios": "^1.8.4", + "dotenv": "^16.5.0", + "dotenv-cli": "^8.0.0", + "eslint": "8.57.1", + "prettier": "^3.5.3", + "prettier-plugin-organize-imports": "^4.1.0", + "tsup": "^8.4.0", + "turbo": "2.5.0", + "typescript": "^5.8.3", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.1.2" + } +} diff --git a/login/packages/zitadel-client/.eslintrc.cjs b/login/packages/zitadel-client/.eslintrc.cjs new file mode 100644 index 0000000000..0eb32ca20d --- /dev/null +++ b/login/packages/zitadel-client/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["@zitadel/eslint-config"], +}; diff --git a/login/packages/zitadel-client/.gitignore b/login/packages/zitadel-client/.gitignore new file mode 100644 index 0000000000..8ff894e88c --- /dev/null +++ b/login/packages/zitadel-client/.gitignore @@ -0,0 +1,4 @@ +src/proto +node_modules +dist +.turbo diff --git a/login/packages/zitadel-client/CHANGELOG.md b/login/packages/zitadel-client/CHANGELOG.md new file mode 100644 index 0000000000..f1107bcc5a --- /dev/null +++ b/login/packages/zitadel-client/CHANGELOG.md @@ -0,0 +1,77 @@ +# @zitadel/client + +## 1.2.0 + +### Minor Changes + +- 62ad388: revert CJS support + +## 1.1.0 + +### Minor Changes + +- 9692297: add CJS and ESM support + +## 1.0.7 + +### Patch Changes + +- Updated dependencies [97b0332] + - @zitadel/proto@1.0.4 + +## 1.0.6 + +### Patch Changes + +- 90fbdd1: use node16/nodenext module resolution +- Updated dependencies [90fbdd1] + - @zitadel/proto@1.0.3 + +## 1.0.5 + +### Patch Changes + +- 4fa22c0: fix export for grpcweb transport + +## 1.0.4 + +### Patch Changes + +- 28dc956: dynamic properties for system token utility + +## 1.0.3 + +### Patch Changes + +- ef1c801: add missing client transport utility + +## 1.0.2 + +### Patch Changes + +- Updated dependencies + - @zitadel/proto@1.0.2 + +## 1.0.1 + +### Patch Changes + +- README updates +- Updated dependencies + - @zitadel/proto@1.0.1 + +## 1.0.0 + +### Major Changes + +- 32e1199: Initial Release + +### Minor Changes + +- f32ab7f: Initial release + +### Patch Changes + +- Updated dependencies [f32ab7f] +- Updated dependencies [32e1199] + - @zitadel/proto@1.0.0 diff --git a/login/packages/zitadel-client/README.md b/login/packages/zitadel-client/README.md new file mode 100644 index 0000000000..0a14fe32f5 --- /dev/null +++ b/login/packages/zitadel-client/README.md @@ -0,0 +1,53 @@ +# ZITADEL Client + +This package exports services and utilities to interact with ZITADEL + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/client +``` + +or + +```sh +yarn add @zitadel/client +``` + +## Usage + +### Importing Services + +You can import and use the services provided by this package to interact with ZITADEL. + +```ts +import { createSettingsServiceClient, makeReqCtx } from "@zitadel/client/v2"; + +// Example usage +const transport = createServerTransport(process.env.ZITADEL_SERVICE_USER_TOKEN!, { baseUrl: process.env.ZITADEL_API_URL! }); + +const settingsService = createSettingsServiceClient(transport); + +settingsService.getBrandingSettings({ ctx: makeReqCtx("orgId") }, {}); +``` + +### Utilities + +This package also provides various utilities to work with ZITADEL + +```ts +import { timestampMs } from "@zitadel/client"; + +// Example usage +console.log(`${timestampMs(session.creationDate)}`); +``` + +## Documentation + +For detailed documentation and API references, please visit the [ZITADEL documentation](https://zitadel.com/docs). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-client/package.json b/login/packages/zitadel-client/package.json new file mode 100644 index 0000000000..298f54f088 --- /dev/null +++ b/login/packages/zitadel-client/package.json @@ -0,0 +1,71 @@ +{ + "name": "@zitadel/client", + "version": "1.2.0", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./v1": { + "types": "./dist/v1.d.ts", + "import": "./dist/v1.js", + "require": "./dist/v1.cjs" + }, + "./v2": { + "types": "./dist/v2.d.ts", + "import": "./dist/v2.js", + "require": "./dist/v2.cjs" + }, + "./v3alpha": { + "types": "./dist/v3alpha.d.ts", + "import": "./dist/v3alpha.js", + "require": "./dist/v3alpha.cjs" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js", + "require": "./dist/node.cjs" + }, + "./web": { + "types": "./dist/web.d.ts", + "import": "./dist/web.js", + "require": "./dist/web.cjs" + } + }, + "files": [ + "dist/**" + ], + "sideEffects": false, + "scripts": { + "build": "pnpm exec tsup", + "build:client:standalone": "pnpm build", + "test": "pnpm test:unit", + "test:watch": "pnpm test:unit:watch", + "test:unit": "pnpm exec vitest", + "test:unit:standalone": "pnpm test:unit", + "test:unit:watch": "pnpm exec vitest --watch", + "dev": "pnpm exec tsup --watch --dts", + "lint": "eslint \"src/**/*.ts*\"", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.2.2", + "@connectrpc/connect": "^2.0.0", + "@connectrpc/connect-node": "^2.0.0", + "@connectrpc/connect-web": "^2.0.0", + "jose": "^5.3.0", + "@zitadel/proto": "workspace:*" + }, + "devDependencies": { + "@bufbuild/protocompile": "^0.0.1", + "@bufbuild/buf": "^1.53.0", + "@zitadel/tsconfig": "workspace:*", + "@zitadel/eslint-config": "workspace:*" + } +} diff --git a/login/packages/zitadel-client/src/helpers.ts b/login/packages/zitadel-client/src/helpers.ts new file mode 100644 index 0000000000..637cadf538 --- /dev/null +++ b/login/packages/zitadel-client/src/helpers.ts @@ -0,0 +1,11 @@ +import type { DescService } from "@bufbuild/protobuf"; +import { Timestamp, timestampDate } from "@bufbuild/protobuf/wkt"; +import { createClient, Transport } from "@connectrpc/connect"; + +export function createClientFor(service: TService) { + return (transport: Transport) => createClient(service, transport); +} + +export function toDate(timestamp: Timestamp | undefined): Date | undefined { + return timestamp ? timestampDate(timestamp) : undefined; +} diff --git a/login/packages/zitadel-client/src/index.ts b/login/packages/zitadel-client/src/index.ts new file mode 100644 index 0000000000..f3f93e593f --- /dev/null +++ b/login/packages/zitadel-client/src/index.ts @@ -0,0 +1,10 @@ +export { createClientFor, toDate } from "./helpers.js"; +export { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +// TODO: Move this to `./protobuf.ts` and export it from there +export { create, fromJson, toJson } from "@bufbuild/protobuf"; +export type { JsonObject } from "@bufbuild/protobuf"; +export type { GenService } from "@bufbuild/protobuf/codegenv1"; +export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt"; +export type { Duration, Timestamp } from "@bufbuild/protobuf/wkt"; +export type { Client, Code, ConnectError } from "@connectrpc/connect"; diff --git a/login/packages/zitadel-client/src/interceptors.test.ts b/login/packages/zitadel-client/src/interceptors.test.ts new file mode 100644 index 0000000000..a5100f866a --- /dev/null +++ b/login/packages/zitadel-client/src/interceptors.test.ts @@ -0,0 +1,67 @@ +import { Int32Value } from "@bufbuild/protobuf/wkt"; +import { compileService } from "@bufbuild/protocompile"; +import { createRouterTransport, HandlerContext } from "@connectrpc/connect"; +import { describe, expect, test, vitest } from "vitest"; +import { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +const TestService = compileService(` + syntax = "proto3"; + package handwritten; + service TestService { + rpc Unary(Int32Value) returns (StringValue); + } + message Int32Value { + int32 value = 1; + } + message StringValue { + string value = 1; + } +`); + +describe("NewAuthorizationBearerInterceptor", () => { + const transport = { + interceptors: [NewAuthorizationBearerInterceptor("mytoken")], + }; + + test("injects the authorization token", async () => { + const handler = vitest.fn((request: Int32Value, context: HandlerContext) => { + return { value: request.value.toString() }; + }); + + const service = createRouterTransport( + ({ rpc }) => { + rpc(TestService.method.unary, handler); + }, + { transport }, + ); + + await service.unary(TestService.method.unary, undefined, undefined, {}, { value: 9001 }); + + expect(handler).toBeCalled(); + expect(handler.mock.calls[0][1].requestHeader.get("Authorization")).toBe("Bearer mytoken"); + }); + + test("do not overwrite the previous authorization token", async () => { + const handler = vitest.fn((request: Int32Value, context: HandlerContext) => { + return { value: request.value.toString() }; + }); + + const service = createRouterTransport( + ({ rpc }) => { + rpc(TestService.method.unary, handler); + }, + { transport }, + ); + + await service.unary( + TestService.method.unary, + undefined, + undefined, + { Authorization: "Bearer somethingelse" }, + { value: 9001 }, + ); + + expect(handler).toBeCalled(); + expect(handler.mock.calls[0][1].requestHeader.get("Authorization")).toBe("Bearer somethingelse"); + }); +}); diff --git a/login/packages/zitadel-client/src/interceptors.ts b/login/packages/zitadel-client/src/interceptors.ts new file mode 100644 index 0000000000..9d719c7d70 --- /dev/null +++ b/login/packages/zitadel-client/src/interceptors.ts @@ -0,0 +1,16 @@ +import type { Interceptor } from "@connectrpc/connect"; + +/** + * Creates an interceptor that adds an Authorization header with a Bearer token. + * @param token + */ +export function NewAuthorizationBearerInterceptor(token: string): Interceptor { + return (next) => (req) => { + // TODO: I am not what is the intent of checking for the Authorization header + // and setting it if it is not present. + if (!req.header.get("Authorization")) { + req.header.set("Authorization", `Bearer ${token}`); + } + return next(req); + }; +} diff --git a/login/packages/zitadel-client/src/node.ts b/login/packages/zitadel-client/src/node.ts new file mode 100644 index 0000000000..0b15310d2c --- /dev/null +++ b/login/packages/zitadel-client/src/node.ts @@ -0,0 +1,36 @@ +import { createGrpcTransport, GrpcTransportOptions } from "@connectrpc/connect-node"; +import { importPKCS8, SignJWT } from "jose"; +import { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +/** + * Create a server transport using grpc with the given token and configuration options. + * @param token + * @param opts + */ +export function createServerTransport(token: string, opts: GrpcTransportOptions) { + return createGrpcTransport({ + ...opts, + interceptors: [...(opts.interceptors || []), NewAuthorizationBearerInterceptor(token)], + }); +} + +export async function newSystemToken({ + audience, + subject, + key, + expirationTime, +}: { + audience: string; + subject: string; + key: string; + expirationTime?: number | string | Date; +}) { + return await new SignJWT({}) + .setProtectedHeader({ alg: "RS256" }) + .setIssuedAt() + .setExpirationTime(expirationTime ?? "1h") + .setIssuer(subject) + .setSubject(subject) + .setAudience(audience) + .sign(await importPKCS8(key, "RS256")); +} diff --git a/login/packages/zitadel-client/src/v1.ts b/login/packages/zitadel-client/src/v1.ts new file mode 100644 index 0000000000..d04180cf88 --- /dev/null +++ b/login/packages/zitadel-client/src/v1.ts @@ -0,0 +1,11 @@ +import { createClientFor } from "./helpers.js"; + +import { AdminService } from "@zitadel/proto/zitadel/admin_pb.js"; +import { AuthService } from "@zitadel/proto/zitadel/auth_pb.js"; +import { ManagementService } from "@zitadel/proto/zitadel/management_pb.js"; +import { SystemService } from "@zitadel/proto/zitadel/system_pb.js"; + +export const createAdminServiceClient = createClientFor(AdminService); +export const createAuthServiceClient = createClientFor(AuthService); +export const createManagementServiceClient = createClientFor(ManagementService); +export const createSystemServiceClient = createClientFor(SystemService); diff --git a/login/packages/zitadel-client/src/v2.ts b/login/packages/zitadel-client/src/v2.ts new file mode 100644 index 0000000000..49cf901734 --- /dev/null +++ b/login/packages/zitadel-client/src/v2.ts @@ -0,0 +1,27 @@ +import { create } from "@bufbuild/protobuf"; +import { FeatureService } from "@zitadel/proto/zitadel/feature/v2/feature_service_pb.js"; +import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb.js"; +import { RequestContextSchema } from "@zitadel/proto/zitadel/object/v2/object_pb.js"; +import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb.js"; +import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb.js"; +import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb.js"; +import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb.js"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb.js"; +import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb.js"; + +import { createClientFor } from "./helpers.js"; + +export const createUserServiceClient = createClientFor(UserService); +export const createSettingsServiceClient = createClientFor(SettingsService); +export const createSessionServiceClient = createClientFor(SessionService); +export const createOIDCServiceClient = createClientFor(OIDCService); +export const createSAMLServiceClient = createClientFor(SAMLService); +export const createOrganizationServiceClient = createClientFor(OrganizationService); +export const createFeatureServiceClient = createClientFor(FeatureService); +export const createIdpServiceClient = createClientFor(IdentityProviderService); + +export function makeReqCtx(orgId: string | undefined) { + return create(RequestContextSchema, { + resourceOwner: orgId ? { case: "orgId", value: orgId } : { case: "instance", value: true }, + }); +} diff --git a/login/packages/zitadel-client/src/v3alpha.ts b/login/packages/zitadel-client/src/v3alpha.ts new file mode 100644 index 0000000000..a5cc533ade --- /dev/null +++ b/login/packages/zitadel-client/src/v3alpha.ts @@ -0,0 +1,6 @@ +import { ZITADELUsers } from "@zitadel/proto/zitadel/resources/user/v3alpha/user_service_pb.js"; +import { ZITADELUserSchemas } from "@zitadel/proto/zitadel/resources/userschema/v3alpha/user_schema_service_pb.js"; +import { createClientFor } from "./helpers.js"; + +export const createUserSchemaServiceClient = createClientFor(ZITADELUserSchemas); +export const createUserServiceClient = createClientFor(ZITADELUsers); diff --git a/login/packages/zitadel-client/src/web.ts b/login/packages/zitadel-client/src/web.ts new file mode 100644 index 0000000000..26c40da0fe --- /dev/null +++ b/login/packages/zitadel-client/src/web.ts @@ -0,0 +1,15 @@ +import { GrpcTransportOptions } from "@connectrpc/connect-node"; +import { createGrpcWebTransport } from "@connectrpc/connect-web"; +import { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +/** + * Create a client transport using grpc web with the given token and configuration options. + * @param token + * @param opts + */ +export function createClientTransport(token: string, opts: GrpcTransportOptions) { + return createGrpcWebTransport({ + ...opts, + interceptors: [...(opts.interceptors || []), NewAuthorizationBearerInterceptor(token)], + }); +} diff --git a/login/packages/zitadel-client/tsconfig.json b/login/packages/zitadel-client/tsconfig.json new file mode 100644 index 0000000000..5f0ea69110 --- /dev/null +++ b/login/packages/zitadel-client/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@zitadel/tsconfig/tsup.json", + "include": ["./src/**/*"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/login/packages/zitadel-client/tsup.config.ts b/login/packages/zitadel-client/tsup.config.ts new file mode 100644 index 0000000000..3c9eeb8b83 --- /dev/null +++ b/login/packages/zitadel-client/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, Options } from "tsup"; + +export default defineConfig((options: Options) => ({ + entry: ["src/index.ts", "src/v1.ts", "src/v2.ts", "src/v3alpha.ts", "src/node.ts", "src/web.ts"], + format: ["esm", "cjs"], + treeshake: false, + splitting: true, + dts: true, + minify: false, + clean: true, + sourcemap: true, + ...options, +})); diff --git a/login/packages/zitadel-client/turbo.json b/login/packages/zitadel-client/turbo.json new file mode 100644 index 0000000000..b54d25e2ba --- /dev/null +++ b/login/packages/zitadel-client/turbo.json @@ -0,0 +1,12 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"], + "dependsOn": ["@zitadel/proto#generate"] + }, + "build:client:standalone": { + "outputs": ["dist/**"] + } + } +} diff --git a/login/packages/zitadel-eslint-config/CHANGELOG.md b/login/packages/zitadel-eslint-config/CHANGELOG.md new file mode 100644 index 0000000000..6759f857ff --- /dev/null +++ b/login/packages/zitadel-eslint-config/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/eslint-config + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-eslint-config/README.md b/login/packages/zitadel-eslint-config/README.md new file mode 100644 index 0000000000..d8d6851f91 --- /dev/null +++ b/login/packages/zitadel-eslint-config/README.md @@ -0,0 +1,35 @@ +# ZITADEL ESLint Config + +This package provides the ESLint configuration used by ZITADEL projects. It includes a set of rules and plugins to ensure consistent code quality and style across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/eslint-config +``` + +or + +```sh +yarn add @zitadel/eslint-config +``` + +## Usage + +To use the ESLint configuration in your project, extend it in your `.eslintrc` file: + +```js +{ + "extends": "@zitadel/eslint-config" +} +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [ESLint documentation](https://eslint.org/docs/user-guide/configuring). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-eslint-config/index.js b/login/packages/zitadel-eslint-config/index.js new file mode 100644 index 0000000000..6a53b2a5e6 --- /dev/null +++ b/login/packages/zitadel-eslint-config/index.js @@ -0,0 +1,13 @@ +module.exports = { + parser: "@babel/eslint-parser", + extends: ["next", "turbo", "prettier"], + rules: { + "@next/next/no-html-link-for-pages": "off", + }, + parserOptions: { + requireConfigFile: false, + babelOptions: { + presets: ["next/babel"], + }, + }, +}; diff --git a/login/packages/zitadel-eslint-config/package.json b/login/packages/zitadel-eslint-config/package.json new file mode 100644 index 0000000000..84c9c76dab --- /dev/null +++ b/login/packages/zitadel-eslint-config/package.json @@ -0,0 +1,17 @@ +{ + "name": "@zitadel/eslint-config", + "version": "0.1.1", + "main": "index.js", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@typescript-eslint/parser": "^7.9.0", + "eslint-config-next": "^14.2.18", + "eslint-config-prettier": "^9.1.0", + "eslint-config-turbo": "^2.0.9", + "eslint-plugin-react": "^7.34.1", + "@babel/eslint-parser": "^7.25.9" + } +} diff --git a/login/packages/zitadel-prettier-config/CHANGELOG.md b/login/packages/zitadel-prettier-config/CHANGELOG.md new file mode 100644 index 0000000000..83045c3320 --- /dev/null +++ b/login/packages/zitadel-prettier-config/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/prettier-config + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-prettier-config/README.md b/login/packages/zitadel-prettier-config/README.md new file mode 100644 index 0000000000..f33913273b --- /dev/null +++ b/login/packages/zitadel-prettier-config/README.md @@ -0,0 +1,36 @@ +# ZITADEL Prettier Config + +This package provides the Prettier configuration used by ZITADEL projects. It includes a set of formatting rules to ensure consistent code style across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/prettier-config +``` + +or + +```sh +yarn add @zitadel/prettier-config +``` + +## Usage + +To use the Prettier configuration in your project, extend it in your `prettier.config.js` file: + +```js +module.exports = { + ...require("@zitadel/prettier-config"), + // Add your custom configurations here +}; +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [Prettier documentation](https://prettier.io/docs/en/configuration.html). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-prettier-config/index.js b/login/packages/zitadel-prettier-config/index.js new file mode 100644 index 0000000000..31d8c455e8 --- /dev/null +++ b/login/packages/zitadel-prettier-config/index.js @@ -0,0 +1,11 @@ +export default { + printWidth: 80, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: false, + trailingComma: 'all', + bracketSpacing: true, + arrowParens: 'always', + plugins: ["prettier-plugin-organize-imports"] +}; diff --git a/login/packages/zitadel-prettier-config/package.json b/login/packages/zitadel-prettier-config/package.json new file mode 100644 index 0000000000..7b7cbf253e --- /dev/null +++ b/login/packages/zitadel-prettier-config/package.json @@ -0,0 +1,12 @@ +{ + "name": "@zitadel/prettier-config", + "version": "0.1.1", + "description": "Prettier configuration", + "type": "module", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": "./index.js" + } +} diff --git a/login/packages/zitadel-proto/.gitignore b/login/packages/zitadel-proto/.gitignore new file mode 100644 index 0000000000..20bdea6767 --- /dev/null +++ b/login/packages/zitadel-proto/.gitignore @@ -0,0 +1,5 @@ +zitadel +google +protoc-gen-openapiv2 +validate +node_modules diff --git a/login/packages/zitadel-proto/CHANGELOG.md b/login/packages/zitadel-proto/CHANGELOG.md new file mode 100644 index 0000000000..c3964e2b29 --- /dev/null +++ b/login/packages/zitadel-proto/CHANGELOG.md @@ -0,0 +1,47 @@ +# @zitadel/proto + +## 1.2.0 + +### Minor Changes + +- 62ad388: revert CJS support + +## 1.1.0 + +### Minor Changes + +- 9692297: add CJS and ESM support + +## 1.0.4 + +### Patch Changes + +- 97b0332: bind @zitadel/proto version to zitadel tag + +## 1.0.3 + +### Patch Changes + +- 90fbdd1: use node16/nodenext module resolution + +## 1.0.2 + +### Patch Changes + +- include validate, google and protoc-gen-openapiv2 + +## 1.0.1 + +### Patch Changes + +- README updates + +## 1.0.0 + +### Major Changes + +- 32e1199: Initial Release + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-proto/README.md b/login/packages/zitadel-proto/README.md new file mode 100644 index 0000000000..bf8a064c12 --- /dev/null +++ b/login/packages/zitadel-proto/README.md @@ -0,0 +1,35 @@ +# ZITADEL Proto + +This package provides the Protocol Buffers (proto) definitions used by ZITADEL projects. It includes the proto files and generated code for interacting with ZITADEL's gRPC APIs. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/proto +``` + +or + +```sh +yarn add @zitadel/proto +``` + +## Usage + +To use the proto definitions in your project, import the generated code: + +```ts +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; + +const org: Organization | null = await getDefaultOrg(); +``` + +## Documentation + +For detailed documentation and API references, please visit the [ZITADEL documentation](https://zitadel.com/docs). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-proto/buf.gen.yaml b/login/packages/zitadel-proto/buf.gen.yaml new file mode 100644 index 0000000000..84ecfaea9d --- /dev/null +++ b/login/packages/zitadel-proto/buf.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +managed: + enabled: true +plugins: + - remote: buf.build/bufbuild/es:v2.2.0 + out: . + include_imports: true + opt: + - json_types=true + - import_extension=js diff --git a/login/packages/zitadel-proto/package.json b/login/packages/zitadel-proto/package.json new file mode 100644 index 0000000000..2c60bced4b --- /dev/null +++ b/login/packages/zitadel-proto/package.json @@ -0,0 +1,26 @@ +{ + "name": "@zitadel/proto", + "version": "1.2.0", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "type": "module", + "files": [ + "zitadel/**", + "validate/**", + "google/**", + "protoc-gen-openapiv2/**" + ], + "sideEffects": false, + "scripts": { + "generate": "pnpm exec buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel", + "clean": "rm -rf zitadel .turbo node_modules google protoc-gen-openapiv2 validate" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.2.2" + }, + "devDependencies": { + "@bufbuild/buf": "^1.53.0" + } +} diff --git a/login/packages/zitadel-proto/turbo.json b/login/packages/zitadel-proto/turbo.json new file mode 100644 index 0000000000..2d24f0349b --- /dev/null +++ b/login/packages/zitadel-proto/turbo.json @@ -0,0 +1,9 @@ +{ + "extends": ["//"], + "tasks": { + "generate": { + "outputs": ["zitadel/**"], + "cache": false + } + } +} diff --git a/login/packages/zitadel-tailwind-config/CHANGELOG.md b/login/packages/zitadel-tailwind-config/CHANGELOG.md new file mode 100644 index 0000000000..e3c4d7207e --- /dev/null +++ b/login/packages/zitadel-tailwind-config/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/tailwind-config + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-tailwind-config/README.md b/login/packages/zitadel-tailwind-config/README.md new file mode 100644 index 0000000000..f52b6c263b --- /dev/null +++ b/login/packages/zitadel-tailwind-config/README.md @@ -0,0 +1,36 @@ +# ZITADEL Tailwind Config + +This package provides the Tailwind CSS configuration used by ZITADEL projects. It includes a set of default styles, themes, and utility classes to ensure consistent design and styling across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/tailwind-config +``` + +or + +```sh +yarn add @zitadel/tailwind-config +``` + +## Usage + +To use the Tailwind CSS configuration in your project, extend it in your `tailwind.config.js` file: + +```js +module.exports = { + presets: [require("@zitadel/tailwind-config")], + // Add your custom configurations here +}; +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [Tailwind CSS documentation](https://tailwindcss.com/docs) + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-tailwind-config/package.json b/login/packages/zitadel-tailwind-config/package.json new file mode 100644 index 0000000000..8fba4bac95 --- /dev/null +++ b/login/packages/zitadel-tailwind-config/package.json @@ -0,0 +1,12 @@ +{ + "name": "@zitadel/tailwind-config", + "version": "0.1.1", + "publishConfig": { + "access": "public" + }, + "main": "index.js", + "devDependencies": { + "tailwindcss": "^4.1.4", + "@tailwindcss/forms": "0.5.3" + } +} diff --git a/login/packages/zitadel-tailwind-config/tailwind.config.mjs b/login/packages/zitadel-tailwind-config/tailwind.config.mjs new file mode 100644 index 0000000000..4a9a437cb7 --- /dev/null +++ b/login/packages/zitadel-tailwind-config/tailwind.config.mjs @@ -0,0 +1,97 @@ +import colors from "tailwindcss/colors"; + +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./app/**/*.{js,ts,jsx,tsx}", "./page/**/*.{js,ts,jsx,tsx}", "./ui/**/*.{js,ts,jsx,tsx}"], + future: { + hoverOnlyWhenSupported: true, + }, + theme: { + extend: { + // https://vercel.com/design/color + fontSize: { + "12px": "12px", + "14px": "14px", + }, + colors: { + gray: colors.zinc, + divider: { + dark: "rgba(135,149,161,.2)", + light: "rgba(135,149,161,.2)", + }, + input: { + light: { + label: "#000000c7", + background: "#00000004", + border: "#1a191954", + hoverborder: "1a1b1b", + }, + dark: { + label: "#ffffffc7", + background: "#00000020", + border: "#f9f7f775", + hoverborder: "#e0e0e0", + }, + }, + button: { + light: { + border: "#0000001f", + }, + dark: { + border: "#ffffff1f", + }, + }, + }, + backgroundImage: ({ theme }) => ({ + "dark-vc-border-gradient": `radial-gradient(at left top, ${theme( + "colors.gray.800", + )}, 50px, ${theme("colors.gray.800")} 50%)`, + "vc-border-gradient": `radial-gradient(at left top, ${theme( + "colors.gray.200", + )}, 50px, ${theme("colors.gray.300")} 50%)`, + }), + keyframes: ({ theme }) => ({ + rerender: { + "0%": { + ["border-color"]: theme("colors.pink.500"), + }, + "40%": { + ["border-color"]: theme("colors.pink.500"), + }, + }, + highlight: { + "0%": { + background: theme("colors.pink.500"), + color: theme("colors.white"), + }, + "40%": { + background: theme("colors.pink.500"), + color: theme("colors.white"), + }, + }, + shimmer: { + "100%": { + transform: "translateX(100%)", + }, + }, + translateXReset: { + "100%": { + transform: "translateX(0)", + }, + }, + fadeToTransparent: { + "0%": { + opacity: 1, + }, + "40%": { + opacity: 1, + }, + "100%": { + opacity: 0, + }, + }, + }), + }, + }, + plugins: [require("@tailwindcss/forms")], +}; diff --git a/login/packages/zitadel-tsconfig/CHANGELOG.md b/login/packages/zitadel-tsconfig/CHANGELOG.md new file mode 100644 index 0000000000..c2d2e7e31c --- /dev/null +++ b/login/packages/zitadel-tsconfig/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/tsconfig + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-tsconfig/README.md b/login/packages/zitadel-tsconfig/README.md new file mode 100644 index 0000000000..b93674b2b1 --- /dev/null +++ b/login/packages/zitadel-tsconfig/README.md @@ -0,0 +1,35 @@ +# ZITADEL TypeScript Config + +This package provides the TypeScript configuration used by ZITADEL projects. It includes a set of rules and settings to ensure consistent TypeScript configuration across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/tsconfig +``` + +or + +```sh +yarn add @zitadel/tsconfig +``` + +## Usage + +To use the TypeScript configuration in your project, extend it in your `tsconfig.json` file: + +```json +{ + "extends": "@zitadel/tsconfig/tsup.json" +} +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [TypeScript documentation](https://www.typescriptlang.org/docs/). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-tsconfig/base.json b/login/packages/zitadel-tsconfig/base.json new file mode 100644 index 0000000000..6d65860cce --- /dev/null +++ b/login/packages/zitadel-tsconfig/base.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Default", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": false, + "isolatedModules": true, + "moduleResolution": "node16", + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true + }, + "exclude": ["node_modules"] +} diff --git a/login/packages/zitadel-tsconfig/nextjs.json b/login/packages/zitadel-tsconfig/nextjs.json new file mode 100644 index 0000000000..eaa9942e1e --- /dev/null +++ b/login/packages/zitadel-tsconfig/nextjs.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Next.js", + "extends": "./base.json", + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "preserveSymlinks": true, + "declaration": false, + "declarationMap": false, + "baseUrl": ".", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["src", "next-env.d.ts"], + "exclude": ["node_modules"] +} diff --git a/login/packages/zitadel-tsconfig/node20.json b/login/packages/zitadel-tsconfig/node20.json new file mode 100644 index 0000000000..bc88cfbee4 --- /dev/null +++ b/login/packages/zitadel-tsconfig/node20.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node 20", + "extends": "./base.json", + "compilerOptions": { + "lib": ["es2023"], + "module": "node16", + "target": "es2022" + } +} diff --git a/login/packages/zitadel-tsconfig/package.json b/login/packages/zitadel-tsconfig/package.json new file mode 100644 index 0000000000..a4d713db85 --- /dev/null +++ b/login/packages/zitadel-tsconfig/package.json @@ -0,0 +1,9 @@ +{ + "name": "@zitadel/tsconfig", + "version": "0.1.1", + "publishConfig": { + "access": "public" + }, + "type": "module", + "license": "MIT" +} diff --git a/login/packages/zitadel-tsconfig/react-library.json b/login/packages/zitadel-tsconfig/react-library.json new file mode 100644 index 0000000000..3f6e3580f4 --- /dev/null +++ b/login/packages/zitadel-tsconfig/react-library.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "React Library", + "extends": "./base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["dom", "ES2015"], + "module": "preserve", + "moduleResolution": "Bundler" + } +} diff --git a/login/packages/zitadel-tsconfig/tsup.json b/login/packages/zitadel-tsconfig/tsup.json new file mode 100644 index 0000000000..1e5cbe42be --- /dev/null +++ b/login/packages/zitadel-tsconfig/tsup.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "tsup", + "extends": "./node20.json" +} diff --git a/login/pnpm-lock.yaml b/login/pnpm-lock.yaml new file mode 100644 index 0000000000..8c6424ccf6 --- /dev/null +++ b/login/pnpm-lock.yaml @@ -0,0 +1,9519 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@typescript-eslint/parser': ^7.9.0 + +importers: + + .: + devDependencies: + '@changesets/cli': + specifier: ^2.29.2 + version: 2.29.2 + '@vitejs/plugin-react': + specifier: ^4.4.1 + version: 4.4.1(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)) + '@zitadel/eslint-config': + specifier: workspace:* + version: link:packages/zitadel-eslint-config + '@zitadel/prettier-config': + specifier: workspace:* + version: link:packages/zitadel-prettier-config + axios: + specifier: ^1.8.4 + version: 1.8.4(debug@4.4.0) + dotenv: + specifier: ^16.5.0 + version: 16.5.0 + dotenv-cli: + specifier: ^8.0.0 + version: 8.0.0 + eslint: + specifier: 8.57.1 + version: 8.57.1 + prettier: + specifier: ^3.5.3 + version: 3.5.3 + prettier-plugin-organize-imports: + specifier: ^4.1.0 + version: 4.1.0(prettier@3.5.3)(typescript@5.8.3) + tsup: + specifier: ^8.4.0 + version: 8.4.0(jiti@1.21.6)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.7.1) + turbo: + specifier: 2.5.0 + version: 2.5.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.8.3)(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)) + vitest: + specifier: ^3.1.2 + version: 3.1.2(@types/node@22.14.1)(jiti@1.21.6)(jsdom@26.1.0)(sass@1.87.0)(yaml@2.7.1) + + apps/login: + dependencies: + '@headlessui/react': + specifier: ^2.1.9 + version: 2.1.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@heroicons/react': + specifier: 2.1.3 + version: 2.1.3(react@19.1.0) + '@tailwindcss/forms': + specifier: 0.5.7 + version: 0.5.7(tailwindcss@3.4.14) + '@vercel/analytics': + specifier: ^1.2.2 + version: 1.3.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) + '@zitadel/client': + specifier: workspace:* + version: link:../../packages/zitadel-client + '@zitadel/proto': + specifier: workspace:* + version: link:../../packages/zitadel-proto + clsx: + specifier: 1.2.1 + version: 1.2.1 + copy-to-clipboard: + specifier: ^3.3.3 + version: 3.3.3 + deepmerge: + specifier: ^4.3.1 + version: 4.3.1 + lucide-react: + specifier: 0.469.0 + version: 0.469.0(react@19.1.0) + moment: + specifier: ^2.29.4 + version: 2.30.1 + next: + specifier: 15.4.0-canary.86 + version: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + next-intl: + specifier: ^3.25.1 + version: 3.26.5(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) + next-themes: + specifier: ^0.2.1 + version: 0.2.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nice-grpc: + specifier: 2.0.1 + version: 2.0.1 + qrcode.react: + specifier: ^3.1.0 + version: 3.1.0(react@19.1.0) + react: + specifier: 19.1.0 + version: 19.1.0 + react-dom: + specifier: 19.1.0 + version: 19.1.0(react@19.1.0) + react-hook-form: + specifier: 7.39.5 + version: 7.39.5(react@19.1.0) + tinycolor2: + specifier: 1.4.2 + version: 1.4.2 + uuid: + specifier: ^11.1.0 + version: 11.1.0 + devDependencies: + '@bufbuild/buf': + specifier: ^1.53.0 + version: 1.53.0 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.6.3 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@types/ms': + specifier: 2.1.0 + version: 2.1.0 + '@types/node': + specifier: ^22.14.1 + version: 22.14.1 + '@types/react': + specifier: 19.1.2 + version: 19.1.2 + '@types/react-dom': + specifier: 19.1.2 + version: 19.1.2(@types/react@19.1.2) + '@types/tinycolor2': + specifier: 1.4.3 + version: 1.4.3 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@vercel/git-hooks': + specifier: 1.0.0 + version: 1.0.0 + '@zitadel/eslint-config': + specifier: workspace:* + version: link:../../packages/zitadel-eslint-config + '@zitadel/prettier-config': + specifier: workspace:* + version: link:../../packages/zitadel-prettier-config + '@zitadel/tailwind-config': + specifier: workspace:* + version: link:../../packages/zitadel-tailwind-config + '@zitadel/tsconfig': + specifier: workspace:* + version: link:../../packages/zitadel-tsconfig + autoprefixer: + specifier: 10.4.21 + version: 10.4.21(postcss@8.5.3) + grpc-tools: + specifier: 1.13.0 + version: 1.13.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + lint-staged: + specifier: 15.5.1 + version: 15.5.1 + make-dir-cli: + specifier: 4.0.0 + version: 4.0.0 + postcss: + specifier: 8.5.3 + version: 8.5.3 + prettier-plugin-tailwindcss: + specifier: 0.6.11 + version: 0.6.11(prettier-plugin-organize-imports@4.1.0(prettier@3.5.3)(typescript@5.8.3))(prettier@3.5.3) + sass: + specifier: ^1.87.0 + version: 1.87.0 + tailwindcss: + specifier: 3.4.14 + version: 3.4.14 + ts-proto: + specifier: ^2.7.0 + version: 2.7.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + apps/login-test-acceptance: + devDependencies: + '@faker-js/faker': + specifier: ^9.7.0 + version: 9.7.0 + '@otplib/core': + specifier: ^12.0.0 + version: 12.0.1 + '@otplib/plugin-crypto': + specifier: ^12.0.0 + version: 12.0.1 + '@otplib/plugin-thirty-two': + specifier: ^12.0.0 + version: 12.0.1 + '@playwright/test': + specifier: ^1.52.0 + version: 1.52.0 + gaxios: + specifier: ^7.1.0 + version: 7.1.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + apps/login-test-integration: + devDependencies: + '@types/node': + specifier: ^22.14.1 + version: 22.14.1 + concurrently: + specifier: ^9.1.2 + version: 9.1.2 + cypress: + specifier: ^14.3.2 + version: 14.3.2 + env-cmd: + specifier: ^10.0.0 + version: 10.1.0 + nodemon: + specifier: ^3.1.9 + version: 3.1.9 + start-server-and-test: + specifier: ^2.0.11 + version: 2.0.11 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + packages/zitadel-client: + dependencies: + '@bufbuild/protobuf': + specifier: ^2.2.2 + version: 2.2.2 + '@connectrpc/connect': + specifier: ^2.0.0 + version: 2.0.0(@bufbuild/protobuf@2.2.2) + '@connectrpc/connect-node': + specifier: ^2.0.0 + version: 2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2)) + '@connectrpc/connect-web': + specifier: ^2.0.0 + version: 2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2)) + '@zitadel/proto': + specifier: workspace:* + version: link:../zitadel-proto + jose: + specifier: ^5.3.0 + version: 5.8.0 + devDependencies: + '@bufbuild/buf': + specifier: ^1.53.0 + version: 1.53.0 + '@bufbuild/protocompile': + specifier: ^0.0.1 + version: 0.0.1(@bufbuild/buf@1.53.0) + '@zitadel/eslint-config': + specifier: workspace:* + version: link:../zitadel-eslint-config + '@zitadel/tsconfig': + specifier: workspace:* + version: link:../zitadel-tsconfig + + packages/zitadel-eslint-config: + dependencies: + '@babel/eslint-parser': + specifier: ^7.25.9 + version: 7.25.9(@babel/core@7.26.10)(eslint@8.57.1) + '@typescript-eslint/parser': + specifier: ^7.9.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.8.3) + eslint-config-next: + specifier: ^14.2.18 + version: 14.2.18(eslint@8.57.1)(typescript@5.8.3) + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.1) + eslint-config-turbo: + specifier: ^2.0.9 + version: 2.1.0(eslint@8.57.1) + eslint-plugin-react: + specifier: ^7.34.1 + version: 7.35.0(eslint@8.57.1) + + packages/zitadel-prettier-config: {} + + packages/zitadel-proto: + dependencies: + '@bufbuild/protobuf': + specifier: ^2.2.2 + version: 2.2.2 + devDependencies: + '@bufbuild/buf': + specifier: ^1.53.0 + version: 1.53.0 + + packages/zitadel-tailwind-config: + devDependencies: + '@tailwindcss/forms': + specifier: 0.5.3 + version: 0.5.3(tailwindcss@4.1.4) + tailwindcss: + specifier: ^4.1.4 + version: 4.1.4 + + packages/zitadel-tsconfig: {} + +packages: + + '@adobe/css-tools@4.4.0': + resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@asamuzakjp/css-color@3.1.4': + resolution: {integrity: sha512-SeuBV4rnjpFNjI8HSgKUwteuFdkHwkboq31HWzznuqgySQir+jSTczoWVVL4jvOjKjuH80fMDG0Fvg1Sb+OJsA==} + + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.26.8': + resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.26.10': + resolution: {integrity: sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==} + engines: {node: '>=6.9.0'} + + '@babel/eslint-parser@7.25.9': + resolution: {integrity: sha512-5UXfgpK0j0Xr/xIdgdLEhOFxaDZ0bRPWJJchRpqOSur/3rZoPbqqki5mm0p4NE2cs28krBEiSM2MB7//afRSQQ==} + engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0} + peerDependencies: + '@babel/core': ^7.11.0 + eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 + + '@babel/generator@7.27.0': + resolution: {integrity: sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.0': + resolution: {integrity: sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.25.9': + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.26.0': + resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.26.5': + resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.25.9': + resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.27.0': + resolution: {integrity: sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.27.0': + resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.25.9': + resolution: {integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.25.9': + resolution: {integrity: sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.27.0': + resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.0': + resolution: {integrity: sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.27.0': + resolution: {integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.27.0': + resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==} + engines: {node: '>=6.9.0'} + + '@bufbuild/buf-darwin-arm64@1.53.0': + resolution: {integrity: sha512-UVhqDYu54ciiCMeG6RODlrX5XRvLN6PfsVDqMQG0JwmMKtUi326CbUqsqO7xsQbcEUso3FTBaURir4RixoM88w==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@bufbuild/buf-darwin-x64@1.53.0': + resolution: {integrity: sha512-03lKaenjf08HF6DlARPU2lEL2dRxNsU6rb9GbUu+YeLayWy7SUlfeDB8drAZ/GpfSc7SL8TKF7jqRkqxT4wFGA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@bufbuild/buf-linux-aarch64@1.53.0': + resolution: {integrity: sha512-FlxrB+rZJG5u7v2JovzXvSR/OdXjVXYHTTLnk6vN/73KPbpGPzZrW7mKxlYyn/Uar5tKDAYvmijjuItXZ6i31g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@bufbuild/buf-linux-armv7@1.53.0': + resolution: {integrity: sha512-e9ER+5Os1DPLhr2X1BRPrQpDZWpv5Mkk2PLnmmzh5RL4kOueJKQZj/m1qQr7SQkiPPhS0yMw7EEghsr521FFzQ==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@bufbuild/buf-linux-x64@1.53.0': + resolution: {integrity: sha512-LehyZPbkRgCvIM56uUnCAUD1QSno2wkBZ5HOvjrjOd0GEjfKgw/fsEu13fJR13bGBNOeOUHbHrd59iUSyY6rGA==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@bufbuild/buf-win32-arm64@1.53.0': + resolution: {integrity: sha512-QRNMHYW6v4keoelIwMNZGQw2R67fsS8lEDnYxrFmiRADwZ/ri/XKJjvQfpoE2Bq0xREB0zZ++RX+1DZOkTA/Iw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@bufbuild/buf-win32-x64@1.53.0': + resolution: {integrity: sha512-relZlT9gYrZGcEH4dcJhEWrjaHV9drG1PcgW6krqw1AzpQOPxR/loXJ7DycoCAnUhQ9TdsdTfUlVHqiJt98piQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@bufbuild/buf@1.53.0': + resolution: {integrity: sha512-GGAztQbbKSv+HaihdDIUpejUcxIx2Fse9SqHfMisJbL/hZ7aOH7BFeSH0q8/g2kSAsLABlenVKeEWKX1uZU3LQ==} + engines: {node: '>=12'} + hasBin: true + + '@bufbuild/protobuf@2.2.2': + resolution: {integrity: sha512-UNtPCbrwrenpmrXuRwn9jYpPoweNXj8X5sMvYgsqYyaH8jQ6LfUJSk3dJLnBK+6sfYPrF4iAIo5sd5HQ+tg75A==} + + '@bufbuild/protobuf@2.2.5': + resolution: {integrity: sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==} + + '@bufbuild/protocompile@0.0.1': + resolution: {integrity: sha512-cOTMtjcWLcbjF17dPYgeMtVC5jZyS0bSjz3jy8kDPjOgjgSYMD2u2It7w8aCc2z23hTPIKl/2SNdMnz0Jzu3Xg==} + peerDependencies: + '@bufbuild/buf': ^1.22.0 + + '@changesets/apply-release-plan@7.0.12': + resolution: {integrity: sha512-EaET7As5CeuhTzvXTQCRZeBUcisoYPDDcXvgTE/2jmmypKp0RC7LxKj/yzqeh/1qFTZI7oDGFcL1PHRuQuketQ==} + + '@changesets/assemble-release-plan@6.0.6': + resolution: {integrity: sha512-Frkj8hWJ1FRZiY3kzVCKzS0N5mMwWKwmv9vpam7vt8rZjLL1JMthdh6pSDVSPumHPshTTkKZ0VtNbE0cJHZZUg==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.29.2': + resolution: {integrity: sha512-vwDemKjGYMOc0l6WUUTGqyAWH3AmueeyoJa1KmFRtCYiCoY5K3B68ErYpDB6H48T4lLI4czum4IEjh6ildxUeg==} + hasBin: true + + '@changesets/config@3.1.1': + resolution: {integrity: sha512-bd+3Ap2TKXxljCggI0mKPfzCQKeV/TU4yO2h2C6vAihIo8tzseAn2e7klSuiyYYXvgu53zMN1OeYMIQkaQoWnA==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.3': + resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + + '@changesets/get-release-plan@4.0.10': + resolution: {integrity: sha512-CCJ/f3edYaA3MqoEnWvGGuZm0uMEMzNJ97z9hdUR34AOvajSwySwsIzC/bBu3+kuGDsB+cny4FljG8UBWAa7jg==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.1': + resolution: {integrity: sha512-iwksMs5Bf/wUItfcg+OXrEpravm5rEd9Bf4oyIPL4kVTmJQ7PNDSd6MDYkpSJR1pn7tz/k8Zf2DhTCqX08Ou+Q==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.5': + resolution: {integrity: sha512-UPzNGhsSjHD3Veb0xO/MwvasGe8eMyNrR/sT9gR8Q3DhOQZirgKhhXv/8hVsI0QpPjR004Z9iFxoJU6in3uGMg==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@connectrpc/connect-node@2.0.0': + resolution: {integrity: sha512-DoI5T+SUvlS/8QBsxt2iDoUg15dSxqhckegrgZpWOtADtmGohBIVbx1UjtWmjLBrP4RdD0FeBw+XyRUSbpKnJQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect': 2.0.0 + + '@connectrpc/connect-web@2.0.0': + resolution: {integrity: sha512-oeCxqHXLXlWJdmcvp9L3scgAuK+FjNSn+twyhUxc8yvDbTumnt5Io+LnBzSYxAdUdYqTw5yHfTSCJ4hj0QID0g==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect': 2.0.0 + + '@connectrpc/connect@2.0.0': + resolution: {integrity: sha512-Usm8jgaaULANJU8vVnhWssSA6nrZ4DJEAbkNtXSoZay2YD5fDyMukCxu8NEhCvFzfHvrhxhcjttvgpyhOM7xAQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + + '@csstools/color-helpers@5.0.2': + resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.3': + resolution: {integrity: sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-color-parser@3.0.9': + resolution: {integrity: sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-parser-algorithms@3.0.4': + resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-tokenizer@3.0.3': + resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} + engines: {node: '>=18'} + + '@cypress/request@3.0.8': + resolution: {integrity: sha512-h0NFgh1mJmm1nr4jCwkGHwKneVYKghUyWe6TMNrk0B9zsjAJxpg8C4/+BAcmLgCPa1vj1V8rNUaILl+zYRUWBQ==} + engines: {node: '>= 6'} + + '@cypress/xvfb@1.2.4': + resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==} + + '@emnapi/runtime@1.4.3': + resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + + '@esbuild/aix-ppc64@0.25.2': + resolution: {integrity: sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.2': + resolution: {integrity: sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.2': + resolution: {integrity: sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.2': + resolution: {integrity: sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.2': + resolution: {integrity: sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.2': + resolution: {integrity: sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.2': + resolution: {integrity: sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.2': + resolution: {integrity: sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.2': + resolution: {integrity: sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.2': + resolution: {integrity: sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.2': + resolution: {integrity: sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.2': + resolution: {integrity: sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.2': + resolution: {integrity: sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.2': + resolution: {integrity: sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.2': + resolution: {integrity: sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.2': + resolution: {integrity: sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.2': + resolution: {integrity: sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.2': + resolution: {integrity: sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.2': + resolution: {integrity: sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.2': + resolution: {integrity: sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.2': + resolution: {integrity: sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.2': + resolution: {integrity: sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.2': + resolution: {integrity: sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.2': + resolution: {integrity: sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.2': + resolution: {integrity: sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/eslint-utils@4.4.1': + resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.11.1': + resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@faker-js/faker@9.7.0': + resolution: {integrity: sha512-aozo5vqjCmDoXLNUJarFZx2IN/GgGaogY4TMJ6so/WLZOWpSV7fvj2dmrV6sEAnUm1O7aCrhTibjpzeDFgNqbg==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + + '@floating-ui/core@1.6.8': + resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} + + '@floating-ui/dom@1.6.11': + resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==} + + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.26.24': + resolution: {integrity: sha512-2ly0pCkZIGEQUq5H8bBK0XJmc1xIK/RM3tvVzY3GBER7IOD1UgmC2Y2tjj4AuS+TC+vTE1KJv2053290jua0Sw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.8': + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + + '@formatjs/ecma402-abstract@2.2.4': + resolution: {integrity: sha512-lFyiQDVvSbQOpU+WFd//ILolGj4UgA/qXrKeZxdV14uKiAUiPAtX6XAn7WBCRi7Mx6I7EybM9E5yYn4BIpZWYg==} + + '@formatjs/fast-memoize@2.2.3': + resolution: {integrity: sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA==} + + '@formatjs/icu-messageformat-parser@2.9.4': + resolution: {integrity: sha512-Tbvp5a9IWuxUcpWNIW6GlMQYEc4rwNHR259uUFoKWNN1jM9obf9Ul0e+7r7MvFOBNcN+13K7NuKCKqQiAn1QEg==} + + '@formatjs/icu-skeleton-parser@1.8.8': + resolution: {integrity: sha512-vHwK3piXwamFcx5YQdCdJxUQ1WdTl6ANclt5xba5zLGDv5Bsur7qz8AD7BevaKxITwpgDeU0u8My3AIibW9ywA==} + + '@formatjs/intl-localematcher@0.5.8': + resolution: {integrity: sha512-I+WDNWWJFZie+jkfkiK5Mp4hEDyRSEvmyfYadflOno/mmKJKcB17fEpEH0oJu/OWhhCJ8kJBDz2YMd/6cDl7Mg==} + + '@grpc/grpc-js@1.11.1': + resolution: {integrity: sha512-gyt/WayZrVPH2w/UTLansS7F9Nwld472JxxaETamrM8HNlsa+jSLNyKAZmhxI2Me4c3mQHFiS1wWHDY1g1Kthw==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.13': + resolution: {integrity: sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==} + engines: {node: '>=6'} + hasBin: true + + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + + '@headlessui/react@2.1.9': + resolution: {integrity: sha512-ckWw7vlKtnoa1fL2X0fx1a3t/Li9MIKDVXn3SgG65YlxvDAsNrY39PPCxVM7sQRA7go2fJsuHSSauKFNaJHH7A==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 + react-dom: ^18 + + '@heroicons/react@2.1.3': + resolution: {integrity: sha512-fEcPfo4oN345SoqdlCDdSa4ivjaKbk0jTd+oubcgNxnNgAfzysfwWfQUr+51wigiWHQQRiZNd1Ao0M5Y3M2EGg==} + peerDependencies: + react: '>= 16' + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@img/sharp-darwin-arm64@0.34.1': + resolution: {integrity: sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.1': + resolution: {integrity: sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.1.0': + resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.1.0': + resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.1.0': + resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.1.0': + resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.1.0': + resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.1.0': + resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.1.0': + resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.1': + resolution: {integrity: sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.1': + resolution: {integrity: sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.34.1': + resolution: {integrity: sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.1': + resolution: {integrity: sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.1': + resolution: {integrity: sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.1': + resolution: {integrity: sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.1': + resolution: {integrity: sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.34.1': + resolution: {integrity: sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.1': + resolution: {integrity: sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + + '@next/env@15.4.0-canary.86': + resolution: {integrity: sha512-WPrEvwqHnjeLx05ncJvqizbBJJFlQGRbxzOnL/pZWKzo19auM9x5Se87P27+E/D/d6jJS801l+thF85lfobAZQ==} + + '@next/eslint-plugin-next@14.2.18': + resolution: {integrity: sha512-KyYTbZ3GQwWOjX3Vi1YcQbekyGP0gdammb7pbmmi25HBUCINzDReyrzCMOJIeZisK1Q3U6DT5Rlc4nm2/pQeXA==} + + '@next/swc-darwin-arm64@15.4.0-canary.86': + resolution: {integrity: sha512-1ofBmzjPkmoMdM+dXvybZ/Roq8HRo0sFzcwXk7/FJNOufuwyK+QKdSpLE7pHlPR7ZREqfEMj61ONO+gAK+zOJw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.4.0-canary.86': + resolution: {integrity: sha512-WCKSrllvwzYi4TgrSdgxKSOF2nhieeaWWOeGucn0OXy50uOAamr0HwP5OaIBCx3oRar4w66gvs4IrdTdMedeJA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.4.0-canary.86': + resolution: {integrity: sha512-8qn7DJVNFjhEIDo2ts0YCsO7g+vJjPWh8Ur8lBK3XspeX0BPsF4s+YmgidrpzRXeIfoo2uYLkkXcy/57CVDblw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.4.0-canary.86': + resolution: {integrity: sha512-8MTn6N4Ja25neMLu2Bra1lqW9AWPqsYg0BVs5M/cxL0QkcN3mak/8LLX1vbzz7GigMGSA+NLwg+ol8lglfgIGA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.4.0-canary.86': + resolution: {integrity: sha512-hIhzDwWDQHnH0M0Pzaqs1c5fa4+LHiLLEBuPJQvhBxQfH+Eh86DWiWHDCaoNiURvdRPg6uCuF2MjwptrMplEkg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.4.0-canary.86': + resolution: {integrity: sha512-FG6SBuSeRWYMNu6tsfaZ4iDzv3BLxlpRncO2xvKKQPeUdDSQ0cehuHYnx8fRte8IOAJ3rlbRd6NXvrDarqu92Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.4.0-canary.86': + resolution: {integrity: sha512-3HvZo4VuyINrNYplRhvC8ILdKwi/vFDHOcTN/I4ru039TFpu2eO6VtXsLBdOdJjGslSSSBYkX+6yRrghihAZDA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.4.0-canary.86': + resolution: {integrity: sha512-UO9JzGGj7GhtSJFdI0Bl0dkIIBfgbhXLsgNVmq9Z/CsUsQB6J9RS/BMhsxfVwhO+RETk13nFpNutMAhAwcuD8w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': + resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@otplib/core@12.0.1': + resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} + + '@otplib/plugin-crypto@12.0.1': + resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==} + + '@otplib/plugin-thirty-two@12.0.1': + resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.52.0': + resolution: {integrity: sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==} + engines: {node: '>=18'} + hasBin: true + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@react-aria/focus@3.18.3': + resolution: {integrity: sha512-WKUElg+5zS0D3xlVn8MntNnkzJql2J6MuzAMP8Sv5WTgFDse/XGR842dsxPTIyKKdrWVCRegCuwa4m3n/GzgJw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-aria/interactions@3.22.3': + resolution: {integrity: sha512-RRUb/aG+P0IKTIWikY/SylB6bIbLZeztnZY2vbe7RAG5MgVaCgn5HQ45SI15GlTmhsFG8CnF6slJsUFJiNHpbQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-aria/ssr@3.9.6': + resolution: {integrity: sha512-iLo82l82ilMiVGy342SELjshuWottlb5+VefO3jOQqQRNYnJBFpUSadswDPbRimSgJUZuFwIEYs6AabkP038fA==} + engines: {node: '>= 12'} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-aria/utils@3.25.3': + resolution: {integrity: sha512-PR5H/2vaD8fSq0H/UB9inNbc8KDcVmW6fYAfSWkkn+OAdhTTMVKqXXrZuZBWyFfSD5Ze7VN6acr4hrOQm2bmrA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-stately/utils@3.10.4': + resolution: {integrity: sha512-gBEQEIMRh5f60KCm7QKQ2WfvhB2gLUr9b72sqUdIZ2EG+xuPgaIlCBeSicvjmjBvYZwOjoOEnmIkcx2GHp/HWw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-types/shared@3.25.0': + resolution: {integrity: sha512-OZSyhzU6vTdW3eV/mz5i6hQwQUhkRs7xwY2d1aqPvTdMe0+2cY7Fwp45PAiwYLEj73i9ro2FxF9qC4DvHGSCgQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@rollup/rollup-android-arm-eabi@4.40.0': + resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.40.0': + resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.40.0': + resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.40.0': + resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.40.0': + resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.40.0': + resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.40.0': + resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.40.0': + resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.40.0': + resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.40.0': + resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.40.0': + resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': + resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.40.0': + resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.40.0': + resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.40.0': + resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.40.0': + resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.40.0': + resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.40.0': + resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.40.0': + resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.40.0': + resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==} + cpu: [x64] + os: [win32] + + '@rushstack/eslint-patch@1.10.4': + resolution: {integrity: sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==} + + '@sideway/address@4.1.5': + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + + '@sideway/formula@3.0.1': + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + + '@sideway/pinpoint@2.0.0': + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@swc/helpers@0.5.5': + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + + '@tailwindcss/forms@0.5.3': + resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' + + '@tailwindcss/forms@0.5.7': + resolution: {integrity: sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' + + '@tanstack/react-virtual@3.10.6': + resolution: {integrity: sha512-xaSy6uUxB92O8mngHZ6CvbhGuqxQ5lIZWCBy+FjhrbHmOwc6BnOnKkYm2FsB1/BpKw/+FVctlMbEtI+F6I1aJg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@tanstack/virtual-core@3.10.6': + resolution: {integrity: sha512-1giLc4dzgEKLMx5pgKjL6HlG5fjZMgCjzlKAlpr7yoUtetVPELgER1NtephAI910nMwfPTHNyWKSFmJdHkz2Cw==} + + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.6.3': + resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.7': + resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + + '@types/estree@1.0.7': + resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@22.14.1': + resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==} + + '@types/react-dom@19.1.2': + resolution: {integrity: sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==} + peerDependencies: + '@types/react': ^19.0.0 + + '@types/react@19.1.2': + resolution: {integrity: sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==} + + '@types/sinonjs__fake-timers@8.1.1': + resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==} + + '@types/sizzle@2.3.9': + resolution: {integrity: sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w==} + + '@types/tinycolor2@1.4.3': + resolution: {integrity: sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript-eslint/eslint-plugin@8.15.0': + resolution: {integrity: sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^7.9.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/scope-manager@8.15.0': + resolution: {integrity: sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.15.0': + resolution: {integrity: sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/types@8.15.0': + resolution: {integrity: sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/typescript-estree@8.15.0': + resolution: {integrity: sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@8.15.0': + resolution: {integrity: sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/visitor-keys@8.15.0': + resolution: {integrity: sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + '@vercel/analytics@1.3.1': + resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==} + peerDependencies: + next: '>= 13' + react: ^18 || ^19 + peerDependenciesMeta: + next: + optional: true + react: + optional: true + + '@vercel/git-hooks@1.0.0': + resolution: {integrity: sha512-OxDFAAdyiJ/H0b8zR9rFCu3BIb78LekBXOphOYG3snV4ULhKFX387pBPpqZ9HLiRTejBWBxYEahkw79tuIgdAA==} + + '@vitejs/plugin-react@4.4.1': + resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + + '@vitest/expect@3.1.2': + resolution: {integrity: sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==} + + '@vitest/mocker@3.1.2': + resolution: {integrity: sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.1.2': + resolution: {integrity: sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==} + + '@vitest/runner@3.1.2': + resolution: {integrity: sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==} + + '@vitest/snapshot@3.1.2': + resolution: {integrity: sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==} + + '@vitest/spy@3.1.2': + resolution: {integrity: sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==} + + '@vitest/utils@3.1.2': + resolution: {integrity: sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + abort-controller-x@0.4.3: + resolution: {integrity: sha512-VtUwTNU8fpMwvWGn4xE93ywbogTYsuT+AUxAXOeelbXuQVIwNmC5YLeho9sH4vZ4ITW8414TTAOG1nW6uIVHCA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + aproba@2.0.0: + resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} + engines: {node: '>= 0.4'} + + array-includes@3.1.8: + resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.5: + resolution: {integrity: sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.2: + resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.2: + resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} + engines: {node: '>= 0.4'} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + + axe-core@4.10.0: + resolution: {integrity: sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==} + engines: {node: '>=4'} + + axios@1.8.4: + resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==} + + axobject-query@3.1.1: + resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + blob-util@2.0.2: + resolution: {integrity: sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cachedir@2.4.0: + resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} + engines: {node: '>=6'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001715: + resolution: {integrity: sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==} + + case-anything@2.1.13: + resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} + engines: {node: '>=12.13'} + + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.4.1: + resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + check-more-types@2.24.0: + resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} + engines: {node: '>= 0.8.0'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + ci-info@4.2.0: + resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==} + engines: {node: '>=8'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concurrently@9.1.2: + resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==} + engines: {node: '>=18'} + hasBin: true + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.3.1: + resolution: {integrity: sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==} + engines: {node: '>=18'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + cypress@14.3.2: + resolution: {integrity: sha512-n+yGD2ZFFKgy7I3YtVpZ7BcFYrrDMcKj713eOZdtxPttpBjCyw/R8dLlFSsJPouneGN7A/HOSRyPJ5+3/gKDoA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.5.0: + resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dotenv-cli@8.0.0: + resolution: {integrity: sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==} + hasBin: true + + dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + + dotenv@16.0.3: + resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} + engines: {node: '>=12'} + + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + engines: {node: '>=12'} + + dprint-node@1.0.8: + resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + + electron-to-chromium@1.5.140: + resolution: {integrity: sha512-o82Rj+ONp4Ip7Cl1r7lrqx/pXhbp/lh9DpKcMNscFJdh8ebyRofnc7Sh01B4jx403RI0oqTBvlZ7OBIZLMr2+Q==} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + + enhanced-resolve@5.17.1: + resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} + engines: {node: '>=10.13.0'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@6.0.0: + resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} + engines: {node: '>=0.12'} + + env-cmd@10.1.0: + resolution: {integrity: sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==} + engines: {node: '>=8.0.0'} + hasBin: true + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + + es-iterator-helpers@1.0.19: + resolution: {integrity: sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.0.2: + resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} + + es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.2: + resolution: {integrity: sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@14.2.18: + resolution: {integrity: sha512-SuDRcpJY5VHBkhz5DijJ4iA4bVnBA0n48Rb+YSJSCDr+h7kKAcb1mZHusLbW+WA8LDB6edSolomXA55eG3eOVA==} + peerDependencies: + eslint: ^7.23.0 || ^8.0.0 + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-config-turbo@2.1.0: + resolution: {integrity: sha512-3SeE2OCWnkA/84adGJXABm++966LNGxRdXtXKBcplJdIe4PmERkov1z6Kzp2PrPKT13wGu/bwoLV5h1rm7v9ug==} + peerDependencies: + eslint: '>6.6.0' + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@3.6.3: + resolution: {integrity: sha512-ud9aw4szY9cCT1EWWdGv1L1XR6hh2PaRWif0j2QjQ0pgTY/69iw+W0Z4qZv5wHahOl8isEr+k/JnyAqNQkLkIA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.8.2: + resolution: {integrity: sha512-3XnC5fDyc8M4J2E8pt8pmSVRX2M+5yWMCfI/kDZwauQeFgzQOuhcRBFKjTeJagqgk4sFKxe1mvNVnaWwImx/Tg==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.29.1: + resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.9.0: + resolution: {integrity: sha512-nOFOCaJG2pYqORjK19lqPqxMO/JpvdCZdPtNdxY3kvom3jTvkAbOvQvD8wuD0G8BYR0IGAGYDlzqWJOh/ybn2g==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + + eslint-plugin-react-hooks@4.6.2: + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + + eslint-plugin-react@7.35.0: + resolution: {integrity: sha512-v501SSMOWv8gerHkk+IIQBkcGRGrO2nfybfj5pLxuJNFTPxxA3PSryhXTK+9pNbtkggheDdsC0E9Q8CuPk6JKA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-plugin-turbo@2.1.0: + resolution: {integrity: sha512-+CWVY29y7Qa+gvrKSzP+TOYrHAlNLCh/97K5VtDdnpH54h/JFmnd3U0aSG6WANe0HgAK8NHQfeWFDdRzfDqbKA==} + peerDependencies: + eslint: '>6.6.0' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.0: + resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-stream@3.3.4: + resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + + eventemitter2@6.4.7: + resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + executable@4.1.1: + resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} + engines: {node: '>=4'} + + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.4.4: + resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + + form-data@4.0.2: + resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + engines: {node: '>= 6'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + from@0.1.7: + resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + gaxios@7.1.0: + resolution: {integrity: sha512-y1Q0MX1Ba6eg67Zz92kW0MHHhdtWksYckQy1KJsI6P4UlDQ8cvdvpLEPslD/k7vFkdPppMESFGTvk7XpSiKj8g==} + engines: {node: '>=18'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.8.0: + resolution: {integrity: sha512-Pgba6TExTZ0FJAn1qkJAjIeKoDJ3CsI2ChuLohJnZl/tTU8MVrq3b+2t5UOPfRa4RMsorClBjJALkJUMjG1PAw==} + + getos@3.2.1: + resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} + + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + grpc-tools@1.13.0: + resolution: {integrity: sha512-7CbkJ1yWPfX0nHjbYG58BQThNhbICXBZynzCUxCb3LzX5X9B3hQbRY2STiRgIEiLILlK9fgl0z0QVGwPCdXf5g==} + hasBin: true + + has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http-signature@1.4.0: + resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} + engines: {node: '>=0.10'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-id@4.1.1: + resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} + hasBin: true + + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + immutable@5.1.1: + resolution: {integrity: sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + + internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} + engines: {node: '>= 0.4'} + + intl-messageformat@10.7.7: + resolution: {integrity: sha512-F134jIoeYMro/3I0h08D0Yt4N9o9pjddU/4IIxMMURqbAtI2wu70X8hvG1V48W49zXHXv3RKSF/po+0fDfsGjA==} + + is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + engines: {node: '>= 0.4'} + + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + + is-async-function@2.0.0: + resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} + engines: {node: '>= 0.4'} + + is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + + is-bun-module@1.1.0: + resolution: {integrity: sha512-4mTAVPlrXpaN3jtF0lsnPCMGnq4+qZjVIKq0HCpfcqf8OC1SM5oATCIAPM5V5FN05qp2NNnFndphmdZS9CV3hA==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} + + is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.0.2: + resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-generator-function@1.0.10: + resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + + is-weakset@2.0.3: + resolution: {integrity: sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==} + engines: {node: '>= 0.4'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + + iterator.prototype@1.1.2: + resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.6: + resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} + hasBin: true + + joi@17.13.3: + resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + + jose@5.8.0: + resolution: {integrity: sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + jsprim@2.0.2: + resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} + engines: {'0': node >=0.6.0} + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + lazy-ass@1.6.0: + resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==} + engines: {node: '> 0.8'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@15.5.1: + resolution: {integrity: sha512-6m7u8mue4Xn6wK6gZvSCQwBvMBR36xfY24nF5bMTf2MHDYG6S3yhJuOgdYVw99hsjyDt2d4z168b3naI8+NWtQ==} + engines: {node: '>=18.12.0'} + hasBin: true + + listr2@3.14.0: + resolution: {integrity: sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==} + engines: {node: '>=10.0.0'} + peerDependencies: + enquirer: '>= 2.3.0 < 3' + peerDependenciesMeta: + enquirer: + optional: true + + listr2@8.3.2: + resolution: {integrity: sha512-vsBzcU4oE+v0lj4FhVLzr9dBTv4/fHIa57l+GCwovP8MoFNZJTOhGU8PXd4v2VJCbECAaijBiHntiekFMLvo0g==} + engines: {node: '>=18.0.0'} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@4.0.0: + resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.1.3: + resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.469.0: + resolution: {integrity: sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + make-dir-cli@4.0.0: + resolution: {integrity: sha512-9BBC2CaGH0hUAx+tQthgxqYypwkTs+7oXmPdiWyDpHGo4mGB3kdudUKQGivK59C1aJroo4QLlXF7Chu/kdhYiw==} + engines: {node: '>=18'} + hasBin: true + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-dir@5.0.0: + resolution: {integrity: sha512-G0yBotnlWVonPClw+tq+xi4K7DZC9n96HjGTBDdHkstAVsDkfZhi1sTvZypXLpyQTbISBkDtK0E5XlUqDsShQg==} + engines: {node: '>=18'} + + map-stream@0.1.0: + resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-intl@3.26.5: + resolution: {integrity: sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg==} + peerDependencies: + next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + + next-themes@0.2.1: + resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} + peerDependencies: + next: '*' + react: '*' + react-dom: '*' + + next@15.4.0-canary.86: + resolution: {integrity: sha512-lGeO0sOvPZ7oFIklqRA863YzRL1bW+kT/OqU3N6RBquHldiucZwnZKQceZdn6WcHEFmWIHzZV+SMG1JEK7hZLg==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + nice-grpc-common@2.0.2: + resolution: {integrity: sha512-7RNWbls5kAL1QVUOXvBsv1uO0wPQK3lHv+cY1gwkTzirnG1Nop4cBJZubpgziNbaVc/bl9QJcyvsf/NQxa3rjQ==} + + nice-grpc@2.0.1: + resolution: {integrity: sha512-Q5CGXO08STsv+HAkXeFgRayANT62X1LnIDhNXdCf+LP0XaP7EiHM0Cr3QefnoFjDZAx/Kxq+qiQfY66BrtKcNQ==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + nodemon@3.1.9: + resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==} + engines: {node: '>=10'} + hasBin: true + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + + nwsapi@2.2.20: + resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} + engines: {node: '>= 0.4'} + + object.entries@1.1.8: + resolution: {integrity: sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.0: + resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + ospath@1.2.2: + resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + + pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + playwright-core@1.52.0: + resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.52.0: + resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==} + engines: {node: '>=18'} + hasBin: true + + possible-typed-array-names@1.0.0: + resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-organize-imports@4.1.0: + resolution: {integrity: sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==} + peerDependencies: + prettier: '>=2.0' + typescript: '>=2.9' + vue-tsc: ^2.1.0 + peerDependenciesMeta: + vue-tsc: + optional: true + + prettier-plugin-tailwindcss@0.6.11: + resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==} + engines: {node: '>=14.21.3'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + prettier@3.5.3: + resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + engines: {node: '>=14'} + hasBin: true + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + protobufjs@7.4.0: + resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==} + engines: {node: '>=12.0.0'} + + proxy-from-env@1.0.0: + resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + ps-tree@1.2.0: + resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} + engines: {node: '>= 0.10'} + hasBin: true + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qrcode.react@3.1.0: + resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + quansync@0.2.10: + resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + peerDependencies: + react: ^19.1.0 + + react-hook-form@7.39.5: + resolution: {integrity: sha512-OE0HKyz5IPc6svN2wd+e+evidZrw4O4WZWAWYzQVZuHi+hYnHFSLnxOq0ddjbdmaLIsLHut/ab7j72y2QT3+KA==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect.getprototypeof@1.0.6: + resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} + engines: {node: '>= 0.4'} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + regexp.prototype.flags@1.5.2: + resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} + engines: {node: '>= 0.4'} + + request-progress@3.0.0: + resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.40.0: + resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.87.0: + resolution: {integrity: sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==} + engines: {node: '>=14.0.0'} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} + hasBin: true + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + sharp@0.34.1: + resolution: {integrity: sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.2: + resolution: {integrity: sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + split@0.3.3: + resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + start-server-and-test@2.0.11: + resolution: {integrity: sha512-TN39gLzPhHAflxyOkE/oMfQGj+pj3JgF6qVicFH/JrXt7xXktidKXwqfRga+ve7lVA8+RgPZVc25VrEPRScaDw==} + engines: {node: '>=16'} + hasBin: true + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + + stop-iteration-iterator@1.0.0: + resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} + engines: {node: '>= 0.4'} + + stream-combiner@0.0.4: + resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string.prototype.includes@2.0.0: + resolution: {integrity: sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg==} + + string.prototype.matchall@4.0.11: + resolution: {integrity: sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + + tailwindcss@3.4.14: + resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==} + engines: {node: '>=14.0.0'} + hasBin: true + + tailwindcss@4.1.4: + resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thirty-two@1.0.2: + resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==} + engines: {node: '>=0.2.6'} + + throttleit@1.0.1: + resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinycolor2@1.4.2: + resolution: {integrity: sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.13: + resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} + engines: {node: '>=12.0.0'} + + tinypool@1.0.2: + resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@1.4.1: + resolution: {integrity: sha512-5RU2/lxTA3YUZxju61HO2U6EoZLvBLtmV2mbTvqyu4a/7s7RmJPT+1YekhMVsQhznRWk/czIwDUg+V8Q9ZuG4w==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-error@1.0.6: + resolution: {integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + ts-poet@6.11.0: + resolution: {integrity: sha512-r5AGF8vvb+GjBsnqiTqbLhN1/U2FJt6BI+k0dfCrkKzWvUhNlwMmq9nDHuucHs45LomgHjZPvYj96dD3JawjJA==} + + ts-proto-descriptors@2.0.0: + resolution: {integrity: sha512-wHcTH3xIv11jxgkX5OyCSFfw27agpInAd6yh89hKG6zqIXnjW9SYqSER2CVQxdPj4czeOhGagNvZBEbJPy7qkw==} + + ts-proto@2.7.0: + resolution: {integrity: sha512-BGHjse2wTOeswOqnnPKinpxmbaRd882so/e1En6ww59YMG7AO9Kg4vPpJcbVfrpBixPRDqHafXD/RDyd2T99GA==} + hasBin: true + + tsconfck@3.1.5: + resolution: {integrity: sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsup@8.4.0: + resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + turbo-darwin-64@2.5.0: + resolution: {integrity: sha512-fP1hhI9zY8hv0idym3hAaXdPi80TLovmGmgZFocVAykFtOxF+GlfIgM/l4iLAV9ObIO4SUXPVWHeBZQQ+Hpjag==} + cpu: [x64] + os: [darwin] + + turbo-darwin-arm64@2.5.0: + resolution: {integrity: sha512-p9sYq7kXH7qeJwIQE86cOWv/xNqvow846l6c/qWc26Ib1ci5W7V0sI5thsrP3eH+VA0d+SHalTKg5SQXgNQBWA==} + cpu: [arm64] + os: [darwin] + + turbo-linux-64@2.5.0: + resolution: {integrity: sha512-1iEln2GWiF3iPPPS1HQJT6ZCFXynJPd89gs9SkggH2EJsj3eRUSVMmMC8y6d7bBbhBFsiGGazwFIYrI12zs6uQ==} + cpu: [x64] + os: [linux] + + turbo-linux-arm64@2.5.0: + resolution: {integrity: sha512-bKBcbvuQHmsX116KcxHJuAcppiiBOfivOObh2O5aXNER6mce7YDDQJy00xQQNp1DhEfcSV2uOsvb3O3nN2cbcA==} + cpu: [arm64] + os: [linux] + + turbo-windows-64@2.5.0: + resolution: {integrity: sha512-9BCo8oQ7BO7J0K913Czbc3tw8QwLqn2nTe4E47k6aVYkM12ASTScweXPTuaPFP5iYXAT6z5Dsniw704Ixa5eGg==} + cpu: [x64] + os: [win32] + + turbo-windows-arm64@2.5.0: + resolution: {integrity: sha512-OUHCV+ueXa3UzfZ4co/ueIHgeq9B2K48pZwIxKSm5VaLVuv8M13MhM7unukW09g++dpdrrE1w4IOVgxKZ0/exg==} + cpu: [arm64] + os: [win32] + + turbo@2.5.0: + resolution: {integrity: sha512-PvSRruOsitjy6qdqwIIyolv99+fEn57gP6gn4zhsHTEcCYgXPhv6BAxzAjleS8XKpo+Y582vTTA9nuqYDmbRuA==} + hasBin: true + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-intl@3.26.5: + resolution: {integrity: sha512-OdsJnC/znPvHCHLQH/duvQNXnP1w0hPfS+tkSi3mAbfjYBGh4JnyfdwkQBfIVf7t8gs9eSX/CntxUMvtKdG2MQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + + vite-node@3.1.2: + resolution: {integrity: sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite@6.3.2: + resolution: {integrity: sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.1.2: + resolution: {integrity: sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.1.2 + '@vitest/ui': 3.1.2 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + wait-on@8.0.3: + resolution: {integrity: sha512-nQFqAFzZDeRxsu7S3C7LbuxslHhk+gnJZHyethuGKAn2IVleIbTB9I3vJSQiSR+DifUqmdzfPMoMPJfLqMF2vw==} + engines: {node: '>=12.0.0'} + hasBin: true + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + + which-builtin-type@1.1.4: + resolution: {integrity: sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.1: + resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.7.1: + resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@adobe/css-tools@4.4.0': {} + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@asamuzakjp/css-color@3.1.4': + dependencies: + '@csstools/css-calc': 2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-color-parser': 3.0.9(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + lru-cache: 10.4.3 + + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.26.8': {} + + '@babel/core@7.26.10': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.27.0 + '@babel/helper-compilation-targets': 7.27.0 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.10) + '@babel/helpers': 7.27.0 + '@babel/parser': 7.27.0 + '@babel/template': 7.27.0 + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 + convert-source-map: 2.0.0 + debug: 4.4.0(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/eslint-parser@7.25.9(@babel/core@7.26.10)(eslint@8.57.1)': + dependencies: + '@babel/core': 7.26.10 + '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 + eslint: 8.57.1 + eslint-visitor-keys: 2.1.0 + semver: 6.3.1 + + '@babel/generator@7.27.0': + dependencies: + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.0': + dependencies: + '@babel/compat-data': 7.26.8 + '@babel/helper-validator-option': 7.25.9 + browserslist: 4.24.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-module-imports@7.25.9': + dependencies: + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.10)': + dependencies: + '@babel/core': 7.26.10 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.27.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.26.5': {} + + '@babel/helper-string-parser@7.25.9': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/helper-validator-option@7.25.9': {} + + '@babel/helpers@7.27.0': + dependencies: + '@babel/template': 7.27.0 + '@babel/types': 7.27.0 + + '@babel/parser@7.27.0': + dependencies: + '@babel/types': 7.27.0 + + '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.10)': + dependencies: + '@babel/core': 7.26.10 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.10)': + dependencies: + '@babel/core': 7.26.10 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/runtime@7.27.0': + dependencies: + regenerator-runtime: 0.14.1 + + '@babel/template@7.27.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + + '@babel/traverse@7.27.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.27.0 + '@babel/parser': 7.27.0 + '@babel/template': 7.27.0 + '@babel/types': 7.27.0 + debug: 4.4.0(supports-color@5.5.0) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.27.0': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@bufbuild/buf-darwin-arm64@1.53.0': + optional: true + + '@bufbuild/buf-darwin-x64@1.53.0': + optional: true + + '@bufbuild/buf-linux-aarch64@1.53.0': + optional: true + + '@bufbuild/buf-linux-armv7@1.53.0': + optional: true + + '@bufbuild/buf-linux-x64@1.53.0': + optional: true + + '@bufbuild/buf-win32-arm64@1.53.0': + optional: true + + '@bufbuild/buf-win32-x64@1.53.0': + optional: true + + '@bufbuild/buf@1.53.0': + optionalDependencies: + '@bufbuild/buf-darwin-arm64': 1.53.0 + '@bufbuild/buf-darwin-x64': 1.53.0 + '@bufbuild/buf-linux-aarch64': 1.53.0 + '@bufbuild/buf-linux-armv7': 1.53.0 + '@bufbuild/buf-linux-x64': 1.53.0 + '@bufbuild/buf-win32-arm64': 1.53.0 + '@bufbuild/buf-win32-x64': 1.53.0 + + '@bufbuild/protobuf@2.2.2': {} + + '@bufbuild/protobuf@2.2.5': {} + + '@bufbuild/protocompile@0.0.1(@bufbuild/buf@1.53.0)': + dependencies: + '@bufbuild/buf': 1.53.0 + '@bufbuild/protobuf': 2.2.2 + fflate: 0.8.2 + + '@changesets/apply-release-plan@7.0.12': + dependencies: + '@changesets/config': 3.1.1 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.7.1 + + '@changesets/assemble-release-plan@6.0.6': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.7.1 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.29.2': + dependencies: + '@changesets/apply-release-plan': 7.0.12 + '@changesets/assemble-release-plan': 6.0.6 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.1 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-release-plan': 4.0.10 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.5 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + ci-info: 3.9.0 + enquirer: 2.4.1 + external-editor: 3.1.0 + fs-extra: 7.0.1 + mri: 1.2.0 + p-limit: 2.3.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.7.1 + spawndamnit: 3.0.1 + term-size: 2.2.1 + + '@changesets/config@3.1.1': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/logger': 0.1.1 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.3': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.7.1 + + '@changesets/get-release-plan@4.0.10': + dependencies: + '@changesets/assemble-release-plan': 6.0.6 + '@changesets/config': 3.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.5 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.1': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 3.14.1 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.5': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.1 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.1.1 + prettier: 2.8.8 + + '@colors/colors@1.5.0': + optional: true + + '@connectrpc/connect-node@2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2))': + dependencies: + '@bufbuild/protobuf': 2.2.2 + '@connectrpc/connect': 2.0.0(@bufbuild/protobuf@2.2.2) + + '@connectrpc/connect-web@2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2))': + dependencies: + '@bufbuild/protobuf': 2.2.2 + '@connectrpc/connect': 2.0.0(@bufbuild/protobuf@2.2.2) + + '@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2)': + dependencies: + '@bufbuild/protobuf': 2.2.2 + + '@csstools/color-helpers@5.0.2': {} + + '@csstools/css-calc@2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-color-parser@3.0.9(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/color-helpers': 5.0.2 + '@csstools/css-calc': 2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-tokenizer@3.0.3': {} + + '@cypress/request@3.0.8': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 4.0.2 + http-signature: 1.4.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.14.0 + safe-buffer: 5.2.1 + tough-cookie: 5.1.2 + tunnel-agent: 0.6.0 + uuid: 8.3.2 + + '@cypress/xvfb@1.2.4(supports-color@8.1.1)': + dependencies: + debug: 3.2.7(supports-color@8.1.1) + lodash.once: 4.1.1 + transitivePeerDependencies: + - supports-color + + '@emnapi/runtime@1.4.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.2': + optional: true + + '@esbuild/android-arm64@0.25.2': + optional: true + + '@esbuild/android-arm@0.25.2': + optional: true + + '@esbuild/android-x64@0.25.2': + optional: true + + '@esbuild/darwin-arm64@0.25.2': + optional: true + + '@esbuild/darwin-x64@0.25.2': + optional: true + + '@esbuild/freebsd-arm64@0.25.2': + optional: true + + '@esbuild/freebsd-x64@0.25.2': + optional: true + + '@esbuild/linux-arm64@0.25.2': + optional: true + + '@esbuild/linux-arm@0.25.2': + optional: true + + '@esbuild/linux-ia32@0.25.2': + optional: true + + '@esbuild/linux-loong64@0.25.2': + optional: true + + '@esbuild/linux-mips64el@0.25.2': + optional: true + + '@esbuild/linux-ppc64@0.25.2': + optional: true + + '@esbuild/linux-riscv64@0.25.2': + optional: true + + '@esbuild/linux-s390x@0.25.2': + optional: true + + '@esbuild/linux-x64@0.25.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.2': + optional: true + + '@esbuild/netbsd-x64@0.25.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.2': + optional: true + + '@esbuild/openbsd-x64@0.25.2': + optional: true + + '@esbuild/sunos-x64@0.25.2': + optional: true + + '@esbuild/win32-arm64@0.25.2': + optional: true + + '@esbuild/win32-ia32@0.25.2': + optional: true + + '@esbuild/win32-x64@0.25.2': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.4.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.11.1': {} + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.0(supports-color@5.5.0) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@faker-js/faker@9.7.0': {} + + '@floating-ui/core@1.6.8': + dependencies: + '@floating-ui/utils': 0.2.8 + + '@floating-ui/dom@1.6.11': + dependencies: + '@floating-ui/core': 1.6.8 + '@floating-ui/utils': 0.2.8 + + '@floating-ui/react-dom@2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/dom': 1.6.11 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@floating-ui/react@0.26.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@floating-ui/utils': 0.2.8 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tabbable: 6.2.0 + + '@floating-ui/utils@0.2.8': {} + + '@formatjs/ecma402-abstract@2.2.4': + dependencies: + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/intl-localematcher': 0.5.8 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.3': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.9.4': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/icu-skeleton-parser': 1.8.8 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.8': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.5.8': + dependencies: + tslib: 2.8.1 + + '@grpc/grpc-js@1.11.1': + dependencies: + '@grpc/proto-loader': 0.7.13 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.13': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.2.3 + protobufjs: 7.4.0 + yargs: 17.7.2 + + '@hapi/hoek@9.3.0': {} + + '@hapi/topo@5.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + + '@headlessui/react@2.1.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react': 0.26.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@react-aria/focus': 3.18.3(react@19.1.0) + '@react-aria/interactions': 3.22.3(react@19.1.0) + '@tanstack/react-virtual': 3.10.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@heroicons/react@2.1.3(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.0(supports-color@5.5.0) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@img/sharp-darwin-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.1.0 + optional: true + + '@img/sharp-darwin-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.1.0 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-darwin-x64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-arm@1.1.0': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-s390x@1.1.0': + optional: true + + '@img/sharp-libvips-linux-x64@1.1.0': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + optional: true + + '@img/sharp-linux-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.1.0 + optional: true + + '@img/sharp-linux-arm@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.1.0 + optional: true + + '@img/sharp-linux-s390x@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.1.0 + optional: true + + '@img/sharp-linux-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.1.0 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + optional: true + + '@img/sharp-wasm32@0.34.1': + dependencies: + '@emnapi/runtime': 1.4.3 + optional: true + + '@img/sharp-win32-ia32@0.34.1': + optional: true + + '@img/sharp-win32-x64@0.34.1': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.27.0 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.27.0 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@mapbox/node-pre-gyp@1.0.11': + dependencies: + detect-libc: 2.0.4 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.7.1 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@next/env@15.4.0-canary.86': {} + + '@next/eslint-plugin-next@14.2.18': + dependencies: + glob: 10.3.10 + + '@next/swc-darwin-arm64@15.4.0-canary.86': + optional: true + + '@next/swc-darwin-x64@15.4.0-canary.86': + optional: true + + '@next/swc-linux-arm64-gnu@15.4.0-canary.86': + optional: true + + '@next/swc-linux-arm64-musl@15.4.0-canary.86': + optional: true + + '@next/swc-linux-x64-gnu@15.4.0-canary.86': + optional: true + + '@next/swc-linux-x64-musl@15.4.0-canary.86': + optional: true + + '@next/swc-win32-arm64-msvc@15.4.0-canary.86': + optional: true + + '@next/swc-win32-x64-msvc@15.4.0-canary.86': + optional: true + + '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': + dependencies: + eslint-scope: 5.1.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@otplib/core@12.0.1': {} + + '@otplib/plugin-crypto@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + + '@otplib/plugin-thirty-two@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + thirty-two: 1.0.2 + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.52.0': + dependencies: + playwright: 1.52.0 + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@react-aria/focus@3.18.3(react@19.1.0)': + dependencies: + '@react-aria/interactions': 3.22.3(react@19.1.0) + '@react-aria/utils': 3.25.3(react@19.1.0) + '@react-types/shared': 3.25.0(react@19.1.0) + '@swc/helpers': 0.5.5 + clsx: 2.1.1 + react: 19.1.0 + + '@react-aria/interactions@3.22.3(react@19.1.0)': + dependencies: + '@react-aria/ssr': 3.9.6(react@19.1.0) + '@react-aria/utils': 3.25.3(react@19.1.0) + '@react-types/shared': 3.25.0(react@19.1.0) + '@swc/helpers': 0.5.5 + react: 19.1.0 + + '@react-aria/ssr@3.9.6(react@19.1.0)': + dependencies: + '@swc/helpers': 0.5.15 + react: 19.1.0 + + '@react-aria/utils@3.25.3(react@19.1.0)': + dependencies: + '@react-aria/ssr': 3.9.6(react@19.1.0) + '@react-stately/utils': 3.10.4(react@19.1.0) + '@react-types/shared': 3.25.0(react@19.1.0) + '@swc/helpers': 0.5.15 + clsx: 2.1.1 + react: 19.1.0 + + '@react-stately/utils@3.10.4(react@19.1.0)': + dependencies: + '@swc/helpers': 0.5.15 + react: 19.1.0 + + '@react-types/shared@3.25.0(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@rollup/rollup-android-arm-eabi@4.40.0': + optional: true + + '@rollup/rollup-android-arm64@4.40.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.40.0': + optional: true + + '@rollup/rollup-darwin-x64@4.40.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.40.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.40.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.40.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.40.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.40.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.40.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.40.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.40.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.40.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.40.0': + optional: true + + '@rushstack/eslint-patch@1.10.4': {} + + '@sideway/address@4.1.5': + dependencies: + '@hapi/hoek': 9.3.0 + + '@sideway/formula@3.0.1': {} + + '@sideway/pinpoint@2.0.0': {} + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@swc/helpers@0.5.5': + dependencies: + '@swc/counter': 0.1.3 + tslib: 2.8.1 + + '@tailwindcss/forms@0.5.3(tailwindcss@4.1.4)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 4.1.4 + + '@tailwindcss/forms@0.5.7(tailwindcss@3.4.14)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 3.4.14 + + '@tanstack/react-virtual@3.10.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/virtual-core': 3.10.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/virtual-core@3.10.6': {} + + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.27.0 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.6.3': + dependencies: + '@adobe/css-tools': 4.4.0 + aria-query: 5.3.0 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.0 + '@testing-library/dom': 10.4.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.2 + '@types/react-dom': 19.1.2(@types/react@19.1.2) + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.7 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.27.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + + '@types/babel__traverse@7.20.7': + dependencies: + '@babel/types': 7.27.0 + + '@types/estree@1.0.7': {} + + '@types/json5@0.0.29': {} + + '@types/ms@2.1.0': {} + + '@types/node@12.20.55': {} + + '@types/node@22.14.1': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.1.2(@types/react@19.1.2)': + dependencies: + '@types/react': 19.1.2 + + '@types/react@19.1.2': + dependencies: + csstype: 3.1.3 + + '@types/sinonjs__fake-timers@8.1.1': {} + + '@types/sizzle@2.3.9': {} + + '@types/tinycolor2@1.4.3': {} + + '@types/uuid@10.0.0': {} + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.14.1 + optional: true + + '@typescript-eslint/eslint-plugin@8.15.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.15.0 + '@typescript-eslint/type-utils': 8.15.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/utils': 8.15.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.15.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + + '@typescript-eslint/scope-manager@8.15.0': + dependencies: + '@typescript-eslint/types': 8.15.0 + '@typescript-eslint/visitor-keys': 8.15.0 + + '@typescript-eslint/type-utils@8.15.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/typescript-estree': 8.15.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.15.0(eslint@8.57.1)(typescript@5.8.3) + debug: 4.4.0(supports-color@5.5.0) + eslint: 8.57.1 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@7.18.0': {} + + '@typescript-eslint/types@8.15.0': {} + + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.0(supports-color@5.5.0) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.1 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.15.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.15.0 + '@typescript-eslint/visitor-keys': 8.15.0 + debug: 4.4.0(supports-color@5.5.0) + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.1 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.15.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.15.0 + '@typescript-eslint/types': 8.15.0 + '@typescript-eslint/typescript-estree': 8.15.0(typescript@5.8.3) + eslint: 8.57.1 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + + '@typescript-eslint/visitor-keys@8.15.0': + dependencies: + '@typescript-eslint/types': 8.15.0 + eslint-visitor-keys: 4.2.0 + + '@ungap/structured-clone@1.2.0': {} + + '@vercel/analytics@1.3.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0)': + dependencies: + server-only: 0.0.1 + optionalDependencies: + next: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + react: 19.1.0 + + '@vercel/git-hooks@1.0.0': {} + + '@vitejs/plugin-react@4.4.1(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1))': + dependencies: + '@babel/core': 7.26.10 + '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10) + '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10) + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.1.2': + dependencies: + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.2.0 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.1.2(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1))': + dependencies: + '@vitest/spy': 3.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + + '@vitest/pretty-format@3.1.2': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.1.2': + dependencies: + '@vitest/utils': 3.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@3.1.2': + dependencies: + '@vitest/pretty-format': 3.1.2 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.1.2': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@3.1.2': + dependencies: + '@vitest/pretty-format': 3.1.2 + loupe: 3.1.3 + tinyrainbow: 2.0.0 + + abbrev@1.1.1: {} + + abort-controller-x@0.4.3: {} + + acorn-jsx@5.3.2(acorn@8.12.1): + dependencies: + acorn: 8.12.1 + + acorn@8.12.1: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.3: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + aproba@2.0.0: {} + + arch@2.2.0: {} + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + array-buffer-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + is-array-buffer: 3.0.4 + + array-includes@3.1.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + is-string: 1.0.7 + + array-union@2.1.0: {} + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + + array.prototype.findlastindex@1.2.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + + array.prototype.flat@1.3.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + + array.prototype.flatmap@1.3.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-shim-unscopables: 1.0.2 + + arraybuffer.prototype.slice@1.0.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + + assertion-error@2.0.1: {} + + ast-types-flow@0.0.8: {} + + astral-regex@2.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + autoprefixer@10.4.21(postcss@8.5.3): + dependencies: + browserslist: 4.24.4 + caniuse-lite: 1.0.30001715 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.0.0 + + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + + axe-core@4.10.0: {} + + axios@1.8.4(debug@4.4.0): + dependencies: + follow-redirects: 1.15.9(debug@4.4.0) + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + axobject-query@3.1.1: + dependencies: + deep-equal: 2.2.3 + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + binary-extensions@2.3.0: {} + + blob-util@2.0.2: {} + + bluebird@3.7.2: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001715 + electron-to-chromium: 1.5.140 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.24.4) + + buffer-crc32@0.2.13: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-require@5.1.0(esbuild@0.25.2): + dependencies: + esbuild: 0.25.2 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + cachedir@2.4.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.7: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001715: {} + + case-anything@2.1.13: {} + + caseless@0.12.0: {} + + chai@5.2.0: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.3 + pathval: 2.0.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.4.1: {} + + chardet@0.7.0: {} + + check-error@2.1.1: {} + + check-more-types@2.24.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@2.0.0: {} + + ci-info@3.9.0: {} + + ci-info@4.2.0: {} + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@1.2.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + optional: true + + color-support@1.1.3: {} + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@13.1.0: {} + + commander@4.1.1: {} + + commander@6.2.1: {} + + common-tags@1.8.2: {} + + concat-map@0.0.1: {} + + concurrently@9.1.2: + dependencies: + chalk: 4.1.2 + lodash: 4.17.21 + rxjs: 7.8.2 + shell-quote: 1.8.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + consola@3.4.2: {} + + console-control-strings@1.1.0: {} + + convert-source-map@2.0.0: {} + + copy-to-clipboard@3.3.3: + dependencies: + toggle-selection: 1.0.6 + + core-util-is@1.0.2: {} + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + cssstyle@4.3.1: + dependencies: + '@asamuzakjp/css-color': 3.1.4 + rrweb-cssom: 0.8.0 + + csstype@3.1.3: {} + + cypress@14.3.2: + dependencies: + '@cypress/request': 3.0.8 + '@cypress/xvfb': 1.2.4(supports-color@8.1.1) + '@types/sinonjs__fake-timers': 8.1.1 + '@types/sizzle': 2.3.9 + arch: 2.2.0 + blob-util: 2.0.2 + bluebird: 3.7.2 + buffer: 5.7.1 + cachedir: 2.4.0 + chalk: 4.1.2 + check-more-types: 2.24.0 + ci-info: 4.2.0 + cli-cursor: 3.1.0 + cli-table3: 0.6.5 + commander: 6.2.1 + common-tags: 1.8.2 + dayjs: 1.11.13 + debug: 4.4.0(supports-color@8.1.1) + enquirer: 2.4.1 + eventemitter2: 6.4.7 + execa: 4.1.0 + executable: 4.1.1 + extract-zip: 2.0.1(supports-color@8.1.1) + figures: 3.2.0 + fs-extra: 9.1.0 + getos: 3.2.1 + is-installed-globally: 0.4.0 + lazy-ass: 1.6.0 + listr2: 3.14.0(enquirer@2.4.1) + lodash: 4.17.21 + log-symbols: 4.1.0 + minimist: 1.2.8 + ospath: 1.2.2 + pretty-bytes: 5.6.0 + process: 0.11.10 + proxy-from-env: 1.0.0 + request-progress: 3.0.0 + semver: 7.7.1 + supports-color: 8.1.1 + tmp: 0.2.3 + tree-kill: 1.2.2 + untildify: 4.0.0 + yauzl: 2.10.0 + + damerau-levenshtein@1.0.8: {} + + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + + data-uri-to-buffer@4.0.1: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + data-view-buffer@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-offset@1.0.0: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + dayjs@1.11.13: {} + + debug@3.2.7(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + debug@4.4.0: + dependencies: + ms: 2.1.3 + + debug@4.4.0(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + debug@4.4.0(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decimal.js@10.5.0: {} + + deep-eql@5.0.2: {} + + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.1.1 + is-array-buffer: 3.0.4 + is-date-object: 1.0.5 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + side-channel: 1.1.0 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.2 + which-typed-array: 1.1.15 + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + delegates@1.0.0: {} + + dequal@2.0.3: {} + + detect-indent@6.1.0: {} + + detect-libc@1.0.3: {} + + detect-libc@2.0.4: {} + + didyoumean@1.2.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dlv@1.1.3: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dotenv-cli@8.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 16.5.0 + dotenv-expand: 10.0.0 + minimist: 1.2.8 + + dotenv-expand@10.0.0: {} + + dotenv@16.0.3: {} + + dotenv@16.5.0: {} + + dprint-node@1.0.8: + dependencies: + detect-libc: 1.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + eastasianwidth@0.2.0: {} + + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + + electron-to-chromium@1.5.140: {} + + emoji-regex@10.4.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.17.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@6.0.0: {} + + env-cmd@10.1.0: + dependencies: + commander: 4.1.1 + cross-spawn: 7.0.6 + + environment@1.1.0: {} + + es-abstract@1.23.3: + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.2 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.3.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.1.1 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.0.7 + isarray: 2.0.5 + stop-iteration-iterator: 1.0.0 + + es-iterator-helpers@1.0.19: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-set-tostringtag: 2.0.3 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + globalthis: 1.0.4 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + internal-slot: 1.0.7 + iterator.prototype: 1.1.2 + safe-array-concat: 1.1.2 + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.0.0: + dependencies: + es-errors: 1.3.0 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.0.3: + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.0.2: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.2.1: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + + esbuild@0.25.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.2 + '@esbuild/android-arm': 0.25.2 + '@esbuild/android-arm64': 0.25.2 + '@esbuild/android-x64': 0.25.2 + '@esbuild/darwin-arm64': 0.25.2 + '@esbuild/darwin-x64': 0.25.2 + '@esbuild/freebsd-arm64': 0.25.2 + '@esbuild/freebsd-x64': 0.25.2 + '@esbuild/linux-arm': 0.25.2 + '@esbuild/linux-arm64': 0.25.2 + '@esbuild/linux-ia32': 0.25.2 + '@esbuild/linux-loong64': 0.25.2 + '@esbuild/linux-mips64el': 0.25.2 + '@esbuild/linux-ppc64': 0.25.2 + '@esbuild/linux-riscv64': 0.25.2 + '@esbuild/linux-s390x': 0.25.2 + '@esbuild/linux-x64': 0.25.2 + '@esbuild/netbsd-arm64': 0.25.2 + '@esbuild/netbsd-x64': 0.25.2 + '@esbuild/openbsd-arm64': 0.25.2 + '@esbuild/openbsd-x64': 0.25.2 + '@esbuild/sunos-x64': 0.25.2 + '@esbuild/win32-arm64': 0.25.2 + '@esbuild/win32-ia32': 0.25.2 + '@esbuild/win32-x64': 0.25.2 + + escalade@3.2.0: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@14.2.18(eslint@8.57.1)(typescript@5.8.3): + dependencies: + '@next/eslint-plugin-next': 14.2.18 + '@rushstack/eslint-patch': 1.10.4 + '@typescript-eslint/eslint-plugin': 8.15.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.1) + eslint-plugin-react: 7.35.0(eslint@8.57.1) + eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-config-prettier@9.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-config-turbo@2.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-plugin-turbo: 2.1.0(eslint@8.57.1) + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7(supports-color@8.1.1) + is-core-module: 2.15.1 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.0(supports-color@5.5.0) + enhanced-resolve: 5.17.1 + eslint: 8.57.1 + eslint-module-utils: 2.8.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1) + fast-glob: 3.3.2 + get-tsconfig: 4.8.0 + is-bun-module: 1.1.0 + is-glob: 4.0.3 + optionalDependencies: + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-node + - eslint-import-resolver-webpack + - supports-color + + eslint-module-utils@2.8.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1): + dependencies: + debug: 3.2.7(supports-color@8.1.1) + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + dependencies: + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.5 + array.prototype.flat: 1.3.2 + array.prototype.flatmap: 1.3.2 + debug: 3.2.7(supports-color@8.1.1) + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.8.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.15.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.0 + semver: 6.3.1 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.9.0(eslint@8.57.1): + dependencies: + aria-query: 5.1.3 + array-includes: 3.1.8 + array.prototype.flatmap: 1.3.2 + ast-types-flow: 0.0.8 + axe-core: 4.10.0 + axobject-query: 3.1.1 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + es-iterator-helpers: 1.0.19 + eslint: 8.57.1 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + safe-regex-test: 1.0.3 + string.prototype.includes: 2.0.0 + + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-react@7.35.0(eslint@8.57.1): + dependencies: + array-includes: 3.1.8 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.2 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.0.19 + eslint: 8.57.1 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.8 + object.fromentries: 2.0.8 + object.values: 1.2.0 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.11 + string.prototype.repeat: 1.0.0 + + eslint-plugin-turbo@2.1.0(eslint@8.57.1): + dependencies: + dotenv: 16.0.3 + eslint: 8.57.1 + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@2.1.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.0: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.11.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.7 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 3.4.3 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.7 + + esutils@2.0.3: {} + + event-stream@3.3.4: + dependencies: + duplexer: 0.1.2 + from: 0.1.7 + map-stream: 0.1.0 + pause-stream: 0.0.11 + split: 0.3.3 + stream-combiner: 0.0.4 + through: 2.3.8 + + eventemitter2@6.4.7: {} + + eventemitter3@5.0.1: {} + + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + executable@4.1.1: + dependencies: + pify: 2.3.0 + + expect-type@1.2.1: {} + + extend@3.0.2: {} + + extendable-error@0.1.7: {} + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + extract-zip@2.0.1(supports-color@8.1.1): + dependencies: + debug: 4.4.0(supports-color@8.1.1) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + extsprintf@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.4.4(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + fflate@0.8.2: {} + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.1: {} + + follow-redirects@1.15.9(debug@4.4.0): + optionalDependencies: + debug: 4.4.0 + + for-each@0.3.3: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + forever-agent@0.6.1: {} + + form-data@4.0.2: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + mime-types: 2.1.35 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fraction.js@4.3.7: {} + + from@0.1.7: {} + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + functions-have-names: 1.2.3 + + functions-have-names@1.2.3: {} + + gauge@3.0.2: + dependencies: + aproba: 2.0.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + gaxios@7.1.0: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.3.0: {} + + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@5.2.0: + dependencies: + pump: 3.0.2 + + get-stream@6.0.1: {} + + get-stream@8.0.1: {} + + get-symbol-description@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + + get-tsconfig@4.8.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + getos@3.2.1: + dependencies: + async: 3.2.6 + + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.3.10: + dependencies: + foreground-child: 3.3.0 + jackspeak: 2.3.6 + minimatch: 9.0.5 + minipass: 7.1.2 + path-scurry: 1.11.1 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + + globals@11.12.0: {} + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.0.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globrex@0.1.2: {} + + gopd@1.0.1: + dependencies: + get-intrinsic: 1.2.4 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + grpc-tools@1.13.0: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + transitivePeerDependencies: + - encoding + - supports-color + + has-bigints@1.0.2: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 + + has-proto@1.0.3: {} + + has-symbols@1.0.3: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + has-unicode@2.0.1: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + http-signature@1.4.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 2.0.2 + sshpk: 1.18.0 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + human-id@4.1.1: {} + + human-signals@1.1.1: {} + + human-signals@2.1.0: {} + + human-signals@5.0.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + immutable@5.1.1: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@2.0.0: {} + + internal-slot@1.0.7: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.0.6 + + intl-messageformat@10.7.7: + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/icu-messageformat-parser': 2.9.4 + tslib: 2.8.1 + + is-arguments@1.1.1: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-array-buffer@3.0.4: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + + is-arrayish@0.3.2: + optional: true + + is-async-function@2.0.0: + dependencies: + has-tostringtag: 1.0.2 + + is-bigint@1.0.4: + dependencies: + has-bigints: 1.0.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.1.2: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-bun-module@1.1.0: + dependencies: + semver: 7.7.1 + + is-callable@1.2.7: {} + + is-core-module@2.15.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.1: + dependencies: + is-typed-array: 1.1.13 + + is-date-object@1.0.5: + dependencies: + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.0.2: + dependencies: + call-bind: 1.0.7 + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.3.0 + + is-generator-function@1.0.10: + dependencies: + has-tostringtag: 1.0.2 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.1.4: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.3: + dependencies: + call-bind: 1.0.7 + + is-stream@2.0.1: {} + + is-stream@3.0.0: {} + + is-string@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-symbol@1.0.4: + dependencies: + has-symbols: 1.0.3 + + is-typed-array@1.1.13: + dependencies: + which-typed-array: 1.1.15 + + is-typedarray@1.0.0: {} + + is-unicode-supported@0.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.0.2: + dependencies: + call-bind: 1.0.7 + + is-weakset@2.0.3: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.3.0 + + is-windows@1.0.2: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isstream@0.1.2: {} + + iterator.prototype@1.1.2: + dependencies: + define-properties: 1.2.1 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + reflect.getprototypeof: 1.0.6 + set-function-name: 2.0.2 + + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.6: {} + + joi@17.13.3: + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + + jose@5.8.0: {} + + joycon@3.1.1: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsbn@0.1.1: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.3.1 + data-urls: 5.0.0 + decimal.js: 10.5.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.20 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-safe@5.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsprim@2.0.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.8 + array.prototype.flat: 1.3.2 + object.assign: 4.1.5 + object.values: 1.2.0 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + lazy-ass@1.6.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@2.1.0: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lint-staged@15.5.1: + dependencies: + chalk: 5.4.1 + commander: 13.1.0 + debug: 4.4.0 + execa: 8.0.1 + lilconfig: 3.1.3 + listr2: 8.3.2 + micromatch: 4.0.8 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.7.1 + transitivePeerDependencies: + - supports-color + + listr2@3.14.0(enquirer@2.4.1): + dependencies: + cli-truncate: 2.1.0 + colorette: 2.0.20 + log-update: 4.0.0 + p-map: 4.0.0 + rfdc: 1.4.1 + rxjs: 7.8.2 + through: 2.3.8 + wrap-ansi: 7.0.0 + optionalDependencies: + enquirer: 2.4.1 + + listr2@8.3.2: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + load-tsconfig@0.2.5: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.sortby@4.7.0: {} + + lodash.startcase@4.4.0: {} + + lodash@4.17.21: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@4.0.0: + dependencies: + ansi-escapes: 4.3.2 + cli-cursor: 3.1.0 + slice-ansi: 4.0.0 + wrap-ansi: 6.2.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + long@5.2.3: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.1.3: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.469.0(react@19.1.0): + dependencies: + react: 19.1.0 + + lz-string@1.5.0: {} + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + make-dir-cli@4.0.0: + dependencies: + make-dir: 5.0.0 + meow: 13.2.0 + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@5.0.0: {} + + map-stream@0.1.0: {} + + math-intrinsics@1.1.0: {} + + meow@13.2.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + min-indent@1.0.1: {} + + mini-svg-data-uri@1.4.4: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: {} + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.2: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + moment@2.30.1: {} + + mri@1.2.0: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + next-intl@3.26.5(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0): + dependencies: + '@formatjs/intl-localematcher': 0.5.8 + negotiator: 1.0.0 + next: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + react: 19.1.0 + use-intl: 3.26.5(react@19.1.0) + + next-themes@0.2.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + next: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0): + dependencies: + '@next/env': 15.4.0-canary.86 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001715 + postcss: 8.4.31 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(react@19.1.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.4.0-canary.86 + '@next/swc-darwin-x64': 15.4.0-canary.86 + '@next/swc-linux-arm64-gnu': 15.4.0-canary.86 + '@next/swc-linux-arm64-musl': 15.4.0-canary.86 + '@next/swc-linux-x64-gnu': 15.4.0-canary.86 + '@next/swc-linux-x64-musl': 15.4.0-canary.86 + '@next/swc-win32-arm64-msvc': 15.4.0-canary.86 + '@next/swc-win32-x64-msvc': 15.4.0-canary.86 + '@playwright/test': 1.52.0 + sass: 1.87.0 + sharp: 0.34.1 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + nice-grpc-common@2.0.2: + dependencies: + ts-error: 1.0.6 + + nice-grpc@2.0.1: + dependencies: + '@grpc/grpc-js': 1.11.1 + abort-controller-x: 0.4.3 + nice-grpc-common: 2.0.2 + + node-addon-api@7.1.1: + optional: true + + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-releases@2.0.19: {} + + nodemon@3.1.9: + dependencies: + chokidar: 3.6.0 + debug: 4.4.0(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.1 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + + nwsapi@2.2.20: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.2: {} + + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + + object.entries@1.1.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + + object.values@1.2.0: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + os-tmpdir@1.0.2: {} + + ospath@1.2.2: {} + + outdent@0.5.0: {} + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@2.1.0: {} + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.10 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + pathval@2.0.0: {} + + pause-stream@0.0.11: + dependencies: + through: 2.3.8 + + pend@1.2.0: {} + + performance-now@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.2: {} + + pidtree@0.6.0: {} + + pify@2.3.0: {} + + pify@4.0.1: {} + + pirates@4.0.7: {} + + playwright-core@1.52.0: {} + + playwright@1.52.0: + dependencies: + playwright-core: 1.52.0 + optionalDependencies: + fsevents: 2.3.2 + + possible-typed-array-names@1.0.0: {} + + postcss-import@15.1.0(postcss@8.5.3): + dependencies: + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + + postcss-js@4.0.1(postcss@8.5.3): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.3 + + postcss-load-config@4.0.2(postcss@8.5.3): + dependencies: + lilconfig: 3.1.3 + yaml: 2.7.1 + optionalDependencies: + postcss: 8.5.3 + + postcss-load-config@6.0.1(jiti@1.21.6)(postcss@8.5.3)(yaml@2.7.1): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.6 + postcss: 8.5.3 + yaml: 2.7.1 + + postcss-nested@6.2.0(postcss@8.5.3): + dependencies: + postcss: 8.5.3 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.3: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-organize-imports@4.1.0(prettier@3.5.3)(typescript@5.8.3): + dependencies: + prettier: 3.5.3 + typescript: 5.8.3 + + prettier-plugin-tailwindcss@0.6.11(prettier-plugin-organize-imports@4.1.0(prettier@3.5.3)(typescript@5.8.3))(prettier@3.5.3): + dependencies: + prettier: 3.5.3 + optionalDependencies: + prettier-plugin-organize-imports: 4.1.0(prettier@3.5.3)(typescript@5.8.3) + + prettier@2.8.8: {} + + prettier@3.5.3: {} + + pretty-bytes@5.6.0: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + process@0.11.10: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + protobufjs@7.4.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.14.1 + long: 5.2.3 + + proxy-from-env@1.0.0: {} + + proxy-from-env@1.1.0: {} + + ps-tree@1.2.0: + dependencies: + event-stream: 3.3.4 + + pstree.remy@1.1.8: {} + + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + punycode@2.3.1: {} + + qrcode.react@3.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + quansync@0.2.10: {} + + queue-microtask@1.2.3: {} + + react-dom@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.26.0 + + react-hook-form@7.39.5(react@19.1.0): + dependencies: + react: 19.1.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-refresh@0.17.0: {} + + react@19.1.0: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.1 + pify: 4.0.1 + strip-bom: 3.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + readdirp@4.1.2: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect.getprototypeof@1.0.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + globalthis: 1.0.4 + which-builtin-type: 1.1.4 + + regenerator-runtime@0.14.1: {} + + regexp.prototype.flags@1.5.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.2 + + request-progress@3.0.0: + dependencies: + throttleit: 1.0.1 + + require-directory@2.1.1: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.0.4: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.40.0: + dependencies: + '@types/estree': 1.0.7 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.40.0 + '@rollup/rollup-android-arm64': 4.40.0 + '@rollup/rollup-darwin-arm64': 4.40.0 + '@rollup/rollup-darwin-x64': 4.40.0 + '@rollup/rollup-freebsd-arm64': 4.40.0 + '@rollup/rollup-freebsd-x64': 4.40.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.40.0 + '@rollup/rollup-linux-arm-musleabihf': 4.40.0 + '@rollup/rollup-linux-arm64-gnu': 4.40.0 + '@rollup/rollup-linux-arm64-musl': 4.40.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.40.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.40.0 + '@rollup/rollup-linux-riscv64-gnu': 4.40.0 + '@rollup/rollup-linux-riscv64-musl': 4.40.0 + '@rollup/rollup-linux-s390x-gnu': 4.40.0 + '@rollup/rollup-linux-x64-gnu': 4.40.0 + '@rollup/rollup-linux-x64-musl': 4.40.0 + '@rollup/rollup-win32-arm64-msvc': 4.40.0 + '@rollup/rollup-win32-ia32-msvc': 4.40.0 + '@rollup/rollup-win32-x64-msvc': 4.40.0 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.2: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-regex-test@1.0.3: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-regex: 1.1.4 + + safer-buffer@2.1.2: {} + + sass@1.87.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.1 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.26.0: {} + + semver@6.3.1: {} + + semver@7.7.1: {} + + server-only@0.0.1: {} + + set-blocking@2.0.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + sharp@0.34.1: + dependencies: + color: 4.2.3 + detect-libc: 2.0.4 + semver: 7.7.1 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.1 + '@img/sharp-darwin-x64': 0.34.1 + '@img/sharp-libvips-darwin-arm64': 1.1.0 + '@img/sharp-libvips-darwin-x64': 1.1.0 + '@img/sharp-libvips-linux-arm': 1.1.0 + '@img/sharp-libvips-linux-arm64': 1.1.0 + '@img/sharp-libvips-linux-ppc64': 1.1.0 + '@img/sharp-libvips-linux-s390x': 1.1.0 + '@img/sharp-libvips-linux-x64': 1.1.0 + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + '@img/sharp-linux-arm': 0.34.1 + '@img/sharp-linux-arm64': 0.34.1 + '@img/sharp-linux-s390x': 0.34.1 + '@img/sharp-linux-x64': 0.34.1 + '@img/sharp-linuxmusl-arm64': 0.34.1 + '@img/sharp-linuxmusl-x64': 0.34.1 + '@img/sharp-wasm32': 0.34.1 + '@img/sharp-win32-ia32': 0.34.1 + '@img/sharp-win32-x64': 0.34.1 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.2: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.2 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + optional: true + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.1 + + slash@3.0.0: {} + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + source-map-js@1.2.1: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + split@0.3.3: + dependencies: + through: 2.3.8 + + sprintf-js@1.0.3: {} + + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + + stackback@0.0.2: {} + + start-server-and-test@2.0.11: + dependencies: + arg: 5.0.2 + bluebird: 3.7.2 + check-more-types: 2.24.0 + debug: 4.4.0 + execa: 5.1.1 + lazy-ass: 1.6.0 + ps-tree: 1.2.0 + wait-on: 8.0.3(debug@4.4.0) + transitivePeerDependencies: + - supports-color + + std-env@3.9.0: {} + + stop-iteration-iterator@1.0.0: + dependencies: + internal-slot: 1.0.7 + + stream-combiner@0.0.4: + dependencies: + duplexer: 0.1.2 + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + + string.prototype.includes@2.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.23.3 + + string.prototype.matchall@4.0.11: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-symbols: 1.0.3 + internal-slot: 1.0.7 + regexp.prototype.flags: 1.5.2 + set-function-name: 2.0.2 + side-channel: 1.0.6 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.23.3 + + string.prototype.trim@1.2.9: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + + string.prototype.trimend@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(react@19.1.0): + dependencies: + client-only: 0.0.1 + react: 19.1.0 + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tabbable@6.2.0: {} + + tailwindcss@3.4.14: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.6 + lilconfig: 2.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-import: 15.1.0(postcss@8.5.3) + postcss-js: 4.0.1(postcss@8.5.3) + postcss-load-config: 4.0.2(postcss@8.5.3) + postcss-nested: 6.2.0(postcss@8.5.3) + postcss-selector-parser: 6.1.2 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + tailwindcss@4.1.4: {} + + tapable@2.2.1: {} + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + term-size@2.2.1: {} + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thirty-two@1.0.2: {} + + throttleit@1.0.1: {} + + through@2.3.8: {} + + tinybench@2.9.0: {} + + tinycolor2@1.4.2: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.13: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + + tinypool@1.0.2: {} + + tinyrainbow@2.0.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + tmp@0.2.3: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toggle-selection@1.0.6: {} + + touch@3.1.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@0.0.3: {} + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + ts-api-utils@1.4.1(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + ts-error@1.0.6: {} + + ts-interface-checker@0.1.13: {} + + ts-poet@6.11.0: + dependencies: + dprint-node: 1.0.8 + + ts-proto-descriptors@2.0.0: + dependencies: + '@bufbuild/protobuf': 2.2.5 + + ts-proto@2.7.0: + dependencies: + '@bufbuild/protobuf': 2.2.5 + case-anything: 2.1.13 + ts-poet: 6.11.0 + ts-proto-descriptors: 2.0.0 + + tsconfck@3.1.5(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsup@8.4.0(jiti@1.21.6)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.7.1): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.0(supports-color@5.5.0) + esbuild: 0.25.2 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@1.21.6)(postcss@8.5.3)(yaml@2.7.1) + resolve-from: 5.0.0 + rollup: 4.40.0 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.3 + typescript: 5.8.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + turbo-darwin-64@2.5.0: + optional: true + + turbo-darwin-arm64@2.5.0: + optional: true + + turbo-linux-64@2.5.0: + optional: true + + turbo-linux-arm64@2.5.0: + optional: true + + turbo-windows-64@2.5.0: + optional: true + + turbo-windows-arm64@2.5.0: + optional: true + + turbo@2.5.0: + optionalDependencies: + turbo-darwin-64: 2.5.0 + turbo-darwin-arm64: 2.5.0 + turbo-linux-64: 2.5.0 + turbo-linux-arm64: 2.5.0 + turbo-windows-64: 2.5.0 + turbo-windows-arm64: 2.5.0 + + tweetnacl@0.14.5: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + typed-array-buffer@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + + typed-array-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-byte-offset@1.0.2: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-length@1.0.6: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + + typescript@5.8.3: {} + + unbox-primitive@1.0.2: + dependencies: + call-bind: 1.0.7 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + + undefsafe@2.0.5: {} + + undici-types@6.21.0: {} + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + untildify@4.0.0: {} + + update-browserslist-db@1.1.3(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-intl@3.26.5(react@19.1.0): + dependencies: + '@formatjs/fast-memoize': 2.2.3 + intl-messageformat: 10.7.7 + react: 19.1.0 + + util-deprecate@1.0.2: {} + + uuid@11.1.0: {} + + uuid@8.3.2: {} + + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + + vite-node@3.1.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1): + dependencies: + cac: 6.7.14 + debug: 4.4.0(supports-color@5.5.0) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)): + dependencies: + debug: 4.4.0(supports-color@5.5.0) + globrex: 0.1.2 + tsconfck: 3.1.5(typescript@5.8.3) + optionalDependencies: + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + transitivePeerDependencies: + - supports-color + - typescript + + vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1): + dependencies: + esbuild: 0.25.2 + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + postcss: 8.5.3 + rollup: 4.40.0 + tinyglobby: 0.2.13 + optionalDependencies: + '@types/node': 22.14.1 + fsevents: 2.3.3 + jiti: 1.21.6 + sass: 1.87.0 + yaml: 2.7.1 + + vitest@3.1.2(@types/node@22.14.1)(jiti@1.21.6)(jsdom@26.1.0)(sass@1.87.0)(yaml@2.7.1): + dependencies: + '@vitest/expect': 3.1.2 + '@vitest/mocker': 3.1.2(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)) + '@vitest/pretty-format': 3.1.2 + '@vitest/runner': 3.1.2 + '@vitest/snapshot': 3.1.2 + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.2.0 + debug: 4.4.0(supports-color@5.5.0) + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 2.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tinypool: 1.0.2 + tinyrainbow: 2.0.0 + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + vite-node: 3.1.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.14.1 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + wait-on@8.0.3(debug@4.4.0): + dependencies: + axios: 1.8.4(debug@4.4.0) + joi: 17.13.3 + lodash: 4.17.21 + minimist: 1.2.8 + rxjs: 7.8.2 + transitivePeerDependencies: + - debug + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@4.0.2: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which-boxed-primitive@1.0.2: + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + + which-builtin-type@1.1.4: + dependencies: + function.prototype.name: 1.1.6 + has-tostringtag: 1.0.2 + is-async-function: 2.0.0 + is-date-object: 1.0.5 + is-finalizationregistry: 1.0.2 + is-generator-function: 1.0.10 + is-regex: 1.1.4 + is-weakref: 1.0.2 + isarray: 2.0.5 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.2 + which-typed-array: 1.1.15 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.3 + + which-typed-array@1.1.15: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + word-wrap@1.2.5: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + ws@8.18.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yaml@2.7.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} diff --git a/login/pnpm-workspace.yaml b/login/pnpm-workspace.yaml new file mode 100644 index 0000000000..3ff5faaaf5 --- /dev/null +++ b/login/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/login/scripts/entrypoint.sh b/login/scripts/entrypoint.sh new file mode 100755 index 0000000000..c537e8b8fb --- /dev/null +++ b/login/scripts/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -o allexport +. /.env-file/.env +set +o allexport + +if [ -n "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ] && [ -f "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ]; then + echo "ZITADEL_SERVICE_USER_TOKEN_FILE=${ZITADEL_SERVICE_USER_TOKEN_FILE} is set and file exists, setting ZITADEL_SERVICE_USER_TOKEN to the files content" + export ZITADEL_SERVICE_USER_TOKEN=$(cat "${ZITADEL_SERVICE_USER_TOKEN_FILE}") +fi + +exec node apps/login/server.js diff --git a/login/scripts/healthcheck.js b/login/scripts/healthcheck.js new file mode 100644 index 0000000000..c1a64c6e75 --- /dev/null +++ b/login/scripts/healthcheck.js @@ -0,0 +1,14 @@ +const url = process.argv[2]; + +if (!url) { + console.error("❌ No URL provided as command line argument."); + process.exit(1); +} + +try { + const res = await fetch(url); + if (!res.ok) process.exit(1); + process.exit(0); +} catch (e) { + process.exit(1); +} diff --git a/login/scripts/run_or_skip.sh b/login/scripts/run_or_skip.sh new file mode 100755 index 0000000000..4516eb01b1 --- /dev/null +++ b/login/scripts/run_or_skip.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# Usage: ./run_or_skip.sh +# Example: ./run_or_skip.sh lint-force "img1;img2" + +set -euo pipefail + +if [ -z "$CACHE_DIR" ]; then + echo "CACHE_DIR is not set. Please set it to a valid directory." + exit 1 +fi + +MAKE_TARGET=$1 +IMAGES=$2 +IGNORE_RUN_CACHE=${IGNORE_RUN_CACHE:-false} + +CACHE_FILE="$CACHE_DIR/$MAKE_TARGET.digests" +mkdir -p "$CACHE_DIR" + +get_image_creation_dates() { + local values="" + for img in $(echo "$IMAGES"); do + local value=$(docker image inspect "$img" --format='{{.Created}}' 2>/dev/null || true) + if [[ -z $value ]]; then + docker pull "$img" >/dev/null 2>&1 || true + value=$(docker image inspect "$img" --format='{{.Created}}' 2>/dev/null || true) + fi + if [[ -z $value ]]; then + value=$(docker image inspect "$img" --format='{{.Created}}' 2>/dev/null || true) + fi + value=${value:-new-and-not-pullable-or-failed-to-build} + value="${img}@${value}" + values="${values}${value};" + done + values=${values%;} # Remove trailing semicolon + echo "$values" +} + +CACHE_FILE_CONTENT=$(cat "$CACHE_FILE" 2>/dev/null || echo "") +CACHED_STATUS=$(echo "$CACHE_FILE_CONTENT" | cut -d ';' -f1) +CACHED_IMAGE_CREATED_VALUES=$(echo "$CACHE_FILE_CONTENT" | cut -d ';' -f2-99) +CURRENT_IMAGE_CREATED_VALUES="$(get_image_creation_dates)" + if [[ "$CACHED_IMAGE_CREATED_VALUES" == "$CURRENT_IMAGE_CREATED_VALUES" ]]; then + if [[ "$IGNORE_RUN_CACHE" == "true" ]]; then + echo "\$IGNORE_RUN_CACHE=$IGNORE_RUN_CACHE - Running $MAKE_TARGET despite unchanged images." + else + echo "Skipping $MAKE_TARGET – all images unchanged, returning cached status $CACHED_STATUS" + exit $CACHED_STATUS + fi +fi +echo "Images have changed" +echo +echo "CACHED_IMAGE_CREATED_VALUES does not match CURRENT_IMAGE_CREATED_VALUES" +echo +echo "$CACHED_IMAGE_CREATED_VALUES" +echo +echo "$CURRENT_IMAGE_CREATED_VALUES" +echo +docker images +echo +echo "Running $MAKE_TARGET..." +set +e +make -j $MAKE_TARGET +STATUS=$? +set -e +echo "${STATUS};$(get_image_creation_dates)" > $CACHE_FILE +exit $STATUS diff --git a/login/turbo.json b/login/turbo.json new file mode 100644 index 0000000000..dabae8fa97 --- /dev/null +++ b/login/turbo.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://turbo.build/schema.json", + "ui": "tui", + "globalDependencies": ["**/.env.*local"], + "globalEnv": [ + "DEBUG", + "VERCEL_URL", + "EMAIL_VERIFICATION", + "AUDIENCE", + "SYSTEM_USER_ID", + "SYSTEM_USER_PRIVATE_KEY", + "ZITADEL_API_URL", + "ZITADEL_SERVICE_USER_TOKEN", + "NEXT_PUBLIC_BASE_PATH", + "CUSTOM_REQUEST_HEADERS", + "NODE_ENV" + ], + "tasks": { + "generate": { + "cache": true + }, + "build": {}, + "build:login:standalone": {}, + "build:client:standalone": {}, + "test": {}, + "start": {}, + "start:built": {}, + "test:unit": {}, + "test:unit:standalone": {}, + "test:integration": {}, + "test:integration:setup": { + "with": ["dev"] + }, + "test:acceptance:setup": {}, + "test:acceptance:setup:dev": { + "with": ["dev"] + }, + "test:watch": { + "persistent": true + }, + "lint": {}, + "lint:fix": {}, + "dev": { + "cache": false, + "persistent": true + }, + "clean": { + "cache": false + } + } +} diff --git a/pkg/grpc/app/v2beta/application.go b/pkg/grpc/app/v2beta/application.go new file mode 100644 index 0000000000..5cef08db46 --- /dev/null +++ b/pkg/grpc/app/v2beta/application.go @@ -0,0 +1,5 @@ +package app + +type ApplicationConfig = isApplication_Config + +type MetaType = isUpdateSAMLApplicationConfigurationRequest_Metadata diff --git a/pkg/grpc/internal_permission/v2beta/resource.go b/pkg/grpc/internal_permission/v2beta/resource.go new file mode 100644 index 0000000000..a57f60242e --- /dev/null +++ b/pkg/grpc/internal_permission/v2beta/resource.go @@ -0,0 +1,3 @@ +package internal_permission + +type Resource = isAdministrator_Resource diff --git a/proto/zitadel/app/v2beta/api.proto b/proto/zitadel/app/v2beta/api.proto new file mode 100644 index 0000000000..9ef09d5ad8 --- /dev/null +++ b/proto/zitadel/app/v2beta/api.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +enum APIAuthMethodType { + API_AUTH_METHOD_TYPE_BASIC = 0; + API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT = 1; +} + +message APIConfig { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334@ZITADEL\""; + description: "generated oauth2/oidc client_id"; + } + ]; + APIAuthMethodType auth_method_type = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines how the API passes the login credentials"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/app.proto b/proto/zitadel/app/v2beta/app.proto new file mode 100644 index 0000000000..f108f3bacb --- /dev/null +++ b/proto/zitadel/app/v2beta/app.proto @@ -0,0 +1,121 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "zitadel/app/v2beta/oidc.proto"; +import "zitadel/app/v2beta/saml.proto"; +import "zitadel/app/v2beta/api.proto"; +import "zitadel/filter/v2/filter.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message Application { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + // The timestamp of the app update. + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + AppState state = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the application"; + } + ]; + string name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Console\""; + } + ]; + oneof config { + OIDCConfig oidc_config = 6; + APIConfig api_config = 7; + SAMLConfig saml_config = 8; + } +} + +enum AppState { + APP_STATE_UNSPECIFIED = 0; + APP_STATE_ACTIVE = 1; + APP_STATE_INACTIVE = 2; + APP_STATE_REMOVED = 3; +} + +enum AppSorting { + APP_SORT_BY_ID = 0; + APP_SORT_BY_NAME = 1; + APP_SORT_BY_STATE = 2; + APP_SORT_BY_CREATION_DATE = 3; + APP_SORT_BY_CHANGE_DATE = 4; +} + +message ApplicationSearchFilter { + oneof filter { + option (validate.required) = true; + ApplicationNameQuery name_filter = 1; + AppState state_filter = 2; + bool api_app_only = 3; + bool oidc_app_only = 4; + bool saml_app_only = 5; + } +} + +message ApplicationNameQuery { + string name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Conso\"" + } + ]; + + zitadel.filter.v2.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used" + } + ]; +} + +enum ApplicationKeysSorting { + APPLICATION_KEYS_SORT_BY_ID = 0; + APPLICATION_KEYS_SORT_BY_PROJECT_ID = 1; + APPLICATION_KEYS_SORT_BY_APPLICATION_ID = 2; + APPLICATION_KEYS_SORT_BY_CREATION_DATE = 3; + APPLICATION_KEYS_SORT_BY_ORGANIZATION_ID = 4; + APPLICATION_KEYS_SORT_BY_EXPIRATION = 5; + APPLICATION_KEYS_SORT_BY_TYPE = 6; +} + +message ApplicationKey { + string id = 1; + string application_id = 2; + string project_id = 3; + google.protobuf.Timestamp creation_date = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + string organization_id = 5; + google.protobuf.Timestamp expiration_date = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/app_service.proto b/proto/zitadel/app/v2beta/app_service.proto new file mode 100644 index 0000000000..61cde73696 --- /dev/null +++ b/proto/zitadel/app/v2beta/app_service.proto @@ -0,0 +1,994 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/app/v2beta/login.proto"; +import "zitadel/app/v2beta/oidc.proto"; +import "zitadel/app/v2beta/api.proto"; +import "zitadel/app/v2beta/app.proto"; +import "google/protobuf/timestamp.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/filter/v2/filter.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Application Service"; + version: "2.0-beta"; + description: "This API is intended to manage apps (SAML, OIDC, etc..) in a ZITADEL instance. This service is in beta state. It can AND will continue breaking until a stable version is released."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// Service to manage apps. +// The service provides methods to create, update, delete and list apps and app keys. +service AppService { + + // Create Application + // + // Create an application. The application can be OIDC, API or SAML type, based on the input. + // + // Required permissions: + // - project.app.write + rpc CreateApplication(CreateApplicationRequest) returns (CreateApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The created application"; + } + }; + }; + + option (google.api.http) = { + post: "/v2beta/applications" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Update Application + // + // Changes the configuration of an OIDC, API or SAML type application, as well as + // the application name, based on the input provided. + // + // Required permissions: + // - project.app.write + rpc UpdateApplication(UpdateApplicationRequest) returns (UpdateApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The updated app."; + } + }; + }; + + option (google.api.http) = { + patch: "/v2beta/applications/{id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Get Application + // + // Retrieves the application matching the provided ID. + // + // Required permissions: + // - project.app.read + rpc GetApplication(GetApplicationRequest) returns (GetApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The fetched app."; + } + }; + }; + + option (google.api.http) = { + get: "/v2beta/applications/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Delete Application + // + // Deletes the application belonging to the input project and matching the provided + // application ID. + // + // Required permissions: + // - project.app.delete + rpc DeleteApplication(DeleteApplicationRequest) returns (DeleteApplicationResponse) { + option (google.api.http) = { + delete: "/v2beta/applications/{id}" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deletion."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Deactivate Application + // + // Deactivates the application belonging to the input project and matching the provided + // application ID. + // + // Required permissions: + // - project.app.write + rpc DeactivateApplication(DeactivateApplicationRequest) returns (DeactivateApplicationResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{id}/deactivate" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deactivation."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Reactivate Application + // + // Reactivates the application belonging to the input project and matching the provided + // application ID. + // + // Required permissions: + // - project.app.write + rpc ReactivateApplication(ReactivateApplicationRequest) returns (ReactivateApplicationResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{id}/reactivate" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of reactivation."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + + // Regenerate Client Secret + // + // Regenerates the client secret of an API or OIDC application that belongs to the input project. + // + // Required permissions: + // - project.app.write + rpc RegenerateClientSecret(RegenerateClientSecretRequest) returns (RegenerateClientSecretResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{application_id}/generate_client_secret" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The regenerated client secret."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // List Applications + // + // Returns a list of applications matching the input parameters that belong to the provided + // project. + // + // The result can be sorted by app id, name, creation date, change date or state. It can also + // be filtered by app state, app type and app name. + // + // Required permissions: + // - project.app.read + rpc ListApplications(ListApplicationsRequest) returns (ListApplicationsResponse) { + option (google.api.http) = { + post: "/v2beta/applications/search" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The matching applications"; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + + // Create Application Key + // + // Create a new application key, which is used to authorize an API application. + // + // Key details are returned in the response. They must be stored safely, as it will not + // be possible to retrieve them again. + // + // Required permissions: + // - `project.app.write` + rpc CreateApplicationKey(CreateApplicationKeyRequest) returns (CreateApplicationKeyResponse) { + option (google.api.http) = { + post: "/v2beta/application_keys" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The created application key"; + } + }; + }; + } + + // Delete Application Key + // + // Deletes an application key matching the provided ID. + // + // Organization ID is not mandatory, but helps with filtering/performance. + // + // The deletion time is returned in response message. + // + // Required permissions: + // - `project.app.write` + rpc DeleteApplicationKey(DeleteApplicationKeyRequest) returns (DeleteApplicationKeyResponse) { + option (google.api.http) = { + delete: "/v2beta/application_keys/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deletion."; + } + }; + }; + } + + // Get Application Key + // + // Retrieves the application key matching the provided ID. + // + // Specifying a project, organization and app ID is optional but help with filtering/performance. + // + // Required permissions: + // - project.app.read + rpc GetApplicationKey(GetApplicationKeyRequest) returns (GetApplicationKeyResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The fetched app key."; + } + }; + }; + + option (google.api.http) = { + get: "/v2beta/application_keys/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // List Application Keys + // + // Returns a list of application keys matching the input parameters. + // + // The result can be sorted by id, aggregate, creation date, expiration date, resource owner or type. + // It can also be filtered by app, project or organization ID. + // + // Required permissions: + // - project.app.read + rpc ListApplicationKeys(ListApplicationKeysRequest) returns (ListApplicationKeysResponse) { + option (google.api.http) = { + post: "/v2beta/application_keys/search" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The matching applications"; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } +} + +message CreateApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {max_len: 200}]; + string name = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"MyApp\""; + } + ]; + oneof creation_request_type { + option (validate.required) = true; + CreateOIDCApplicationRequest oidc_request = 4; + CreateSAMLApplicationRequest saml_request = 5; + CreateAPIApplicationRequest api_request = 6; + } +} + +message CreateApplicationResponse { + string app_id = 1; + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + oneof creation_response_type { + CreateOIDCApplicationResponse oidc_response = 3; + CreateSAMLApplicationResponse saml_response = 4; + CreateAPIApplicationResponse api_response = 5; + } +} + +message CreateOIDCApplicationRequest { + // Callback URI of the authorization request where the code or tokens will be sent to + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + OIDCAppType app_type = 4 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines the paradigm of the application"; + } + ]; + OIDCAuthMethodType auth_method_type = 5 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines how the application passes login credentials"; + } + ]; + + // ZITADEL will redirect to this link after a successful logout + repeated string post_logout_redirect_uris = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/signedout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + OIDCVersion version = 7 [(validate.rules).enum = {defined_only: true}]; + bool dev_mode = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used for development, some checks of the OIDC specification will not be checked."; + } + ]; + OIDCTokenType access_token_type = 9 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Type of the access token returned from ZITADEL"; + } + ]; + bool access_token_role_assertion = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + bool id_token_role_assertion = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + bool id_token_userinfo_assertion = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + google.protobuf.Duration clock_skew = 13 [ + (validate.rules).duration = {gte: {}, lte: {seconds: 5}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + example: "\"1s\""; + } + ]; + repeated string additional_origins = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"scheme://localhost:8080\"]"; + description: "Additional origins (other than the redirect_uris) from where the API can be used, provided string has to be an origin (scheme://hostname[:port]) without path, query or fragment"; + } + ]; + bool skip_native_app_success_page = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + string back_channel_logout_uri = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + LoginVersion login_version = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message CreateOIDCApplicationResponse { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"1035496534033449\""; + description: "generated client id for this config"; + } + ]; + string client_secret = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for this config"; + } + ]; + bool none_compliant = 3; + repeated OIDCLocalizedMessage compliance_problems = 4; +} + +message CreateSAMLApplicationRequest { + oneof metadata { + option (validate.required) = true; + bytes metadata_xml = 1 [(validate.rules).bytes.max_len = 500000]; + string metadata_url = 2 [(validate.rules).string.max_len = 200]; + } + LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message CreateSAMLApplicationResponse {} + +message CreateAPIApplicationRequest { + APIAuthMethodType auth_method_type = 1 [(validate.rules).enum = {defined_only: true}]; +} + +message CreateAPIApplicationResponse { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"3950723409029374\""; + description: "generated secret for this config"; + } + ]; + string client_secret = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for this config"; + } + ]; +} + +message UpdateApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"45984352431\""; + } + ]; + string name = 3 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"MyApplicationName\""; + min_length: 1; + max_length: 200; + } + ]; + + oneof update_request_type { + UpdateSAMLApplicationConfigurationRequest saml_configuration_request = 4; + UpdateOIDCApplicationConfigurationRequest oidc_configuration_request = 5; + UpdateAPIApplicationConfigurationRequest api_configuration_request = 6; + } +} + +message UpdateApplicationResponse { + // The timestamp of the app update. + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message UpdateSAMLApplicationConfigurationRequest { + oneof metadata { + option (validate.required) = true; + bytes metadata_xml = 1 [(validate.rules).bytes.max_len = 500000]; + string metadata_url = 2 [(validate.rules).string.max_len = 200]; + } + optional LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message UpdateOIDCApplicationConfigurationRequest { + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + optional OIDCAppType app_type = 4 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines the paradigm of the application"; + } + ]; + optional OIDCAuthMethodType auth_method_type = 5 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines how the application passes login credentials"; + } + ]; + repeated string post_logout_redirect_uris = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/signedout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + optional OIDCVersion version = 7 [(validate.rules).enum = {defined_only: true}]; + optional bool dev_mode = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used for development, some checks of the OIDC specification will not be checked."; + } + ]; + optional OIDCTokenType access_token_type = 9 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Type of the access token returned from ZITADEL"; + } + ]; + optional bool access_token_role_assertion = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + optional bool id_token_role_assertion = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + optional bool id_token_userinfo_assertion = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + optional google.protobuf.Duration clock_skew = 13 [ + (validate.rules).duration = {gte: {}, lte: {seconds: 5}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + example: "\"1s\""; + } + ]; + repeated string additional_origins = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"scheme://localhost:8080\"]"; + description: "Additional origins (other than the redirect_uris) from where the API can be used, provided string has to be an origin (scheme://hostname[:port]) without path, query or fragment"; + } + ]; + optional bool skip_native_app_success_page = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + optional string back_channel_logout_uri = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + optional LoginVersion login_version = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message UpdateAPIApplicationConfigurationRequest { + APIAuthMethodType auth_method_type = 1 [(validate.rules).enum = {defined_only: true}]; +} + +message GetApplicationRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"45984352431\""; + } + ]; +} + +message GetApplicationResponse { + Application app = 1; +} + +message DeleteApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message DeleteApplicationResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeactivateApplicationRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message DeactivateApplicationResponse{ + google.protobuf.Timestamp deactivation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ReactivateApplicationRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message ReactivateApplicationResponse{ + google.protobuf.Timestamp reactivation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message RegenerateClientSecretRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string application_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + oneof app_type { + option (validate.required) = true; + bool is_oidc = 3; + bool is_api = 4; + } +} + +message RegenerateClientSecretResponse{ + string client_secret = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for the client"; + } + ]; + + // The timestamp of the creation of the new client secret + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListApplicationsRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + + // Pagination and sorting. + zitadel.filter.v2.PaginationRequest pagination = 2; + + //criteria the client is looking for + repeated ApplicationSearchFilter filters = 3; + + AppSorting sorting_column = 4; +} + +message ListApplicationsResponse { + repeated Application applications = 1; + + // Contains the total number of apps matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} + +message CreateApplicationKeyRequest { + string app_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + + string project_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + + // The date the key will expire + google.protobuf.Timestamp expiration_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2519-04-01T08:45:00.000000Z\""; + description: "The date the key will expire"; + } + ]; +} + +message CreateApplicationKeyResponse { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"28746028909593987\""; + } + ]; + + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + bytes key_details = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"eyJ0eXBlIjoiYXBwbGljYXRpb24iLCJrZXlJZCI6IjIwMjcxMDE4NjYyMjcxNDExMyIsImtleSI6Ii0tLS0tQkVHSU4gUlNBIFBSSVZBVEUgS0VZLS0tLS1cbk1JSUVvd0lCQUFLQ0FRRUFuMUxyNStTV0pGRllURU1kaXQ2U0dNY0E2Yks5dG0xMmhlcm55V0wrZm9PWnA3eEVcbk9wcmsvWE81QVplSU5NY0x0ZVhxckJlK1NPdVVNMFpLU2xCMHFTNzNjVStDVTVMTGoycVB0UzhNOFI0N3BGdFhcbjJXRTFJNjNhZHB1N01TejA2SXduQ2lyNnJYOTVPQ2ZneHA3VU1Dd0pSTUZmYXJqdjVBRXY3NXpsSS9lYUV6bUJcbkxKWU1xanZFRmZoN2x3M2lPT3VsWW9kNjNpN3RDNWl5czNlYjNLZW4yWU0rN1FSbXB2dE5qcTJMVmlIMnkrUGJcbk9ESlI3MU9ib05TYVJDNTZDUFpWVytoWDByYXI3VzMwUjI2eGtIQ09oSytQbUpSeGtOY0g1VTdja0xXMEw0WEVcbnNNZkVUSmszeDR3Q0psbisxbElXUzkrNmw0R1E2TWRzWURyOU5RSURBUUFCQW9JQkFCSkx6WGQxMHFBZEQwekNcbnNGUFFOMnJNLzVmV3hONThONDR0YWF6QXg0VHp5K050UlZDTmxScGQvYkxuR2VjbHJIeVpDSmYycWcxcHNEMHJcbkowRGRlR2d0VXBFYWxsYk9scjNEZVBsUGkrYnNsK0RKOUk2c0VSUWwxTjZtQjVzZ0ZJZllBR3UwZjlFSXdIem9cblozR25yNnBRaEVmM0JPUVdsTVhVTlJNSksyOHp3M2E1L01nRmtKVUZUSTUzeXFwbGRtZ2hLajRZR1hLRk1LUGhcbkV3RkxrRncwK2s3K0xuSjFQNGp1ZVd1RXo3WlAyaFpvUWxCcXdSajVyTG9QZ05RbUU4UytFVDRuczlUYzByOFFcbnFyaHlacDZBczJrTDhGTytCZnF3SVpDZnpnWHN2cC9PLzRaSHIzVTB2Ymp3UW1sSzdVSm42U0J6T2hpWFpNU0lcbk5Wc0V5VUVDZ1lFQTFEaktkRGo3NTM1MWQzdlRNQlRFd2JSQ3hoUVZOdENFMnMwVUw4ckJQZ1I0K1dlblNUWmFcbnprWUprcEV0bE54VGxzYnN1Y0RTUXZqeWRYYk5nSHFBeDYzMm1vdTVkak9lR0VTUDFWVGtUdElsZFZQZWszQWxcbjVYbkpQa1dqWGVyVVJZNm5KeUQ5UWhlREx3MVp4NEFYVzNHWURiTFkrT05XV0VKUlJaQUloNjBDZ1lFQXdEQ2xcbnc1MHc4dkcvbEJ4RzNSYW9FaHdLOWNna1VXOHk2T25DekNwcEtjOEZUUmY1VE5iWjl5TzNXUmdYajhkeHRCakFcbkl5VGlzYk9NQk1VaFZKUUtGZHRQaDhoVDBwRkRjeE9ndzY0aHBtYzhyY2RTbXVKNzlYSVRTaHUySjA0N0UvNFZcbnJOTThpWVk5ZGR3VGdGUUlsdFNZL0l0RnFxWERmdjhqK1dVY25La0NnWUVBaENOUU80bDNuNjRucWR2WnBTaHBcblVrclJBTkJrWFJyOGZkZ1BaNnFSSS9KWStNSEhjVmg4dGM3NkN0NkdTUmZlbkJVRU5LeVF2czZPK1FDZCtBOU9cbnZBWGZkRjduZldlcVdtWG1RT2g0dDNNMWk1WkxFZlpVUWt2UU9BdllLcFFhMDZ4OCsyb1pCdHZvL0pVTmY2Q0xcbjZvNFNKUVZrLzZOZGtkckpDODBnNG9rQ2dZQkZsNWYrbkVYa1F0dWZVeG5wNXRGWE5XWldsM0ZuTjMvVXpRaW5cbmkxZm5OcnB4cnhPcjJrUzA4KzdwU1FzSEdpNDNDNXRQWG9UajJlTUN1eXNWaUVHYXBuNUc2YWhJb0NjdlhWVWlcblprUnpFQUR0NERZdU5ZS3pYdXBUTkhPaUNmYmtoMlhyM2RXVzZ0QUloSGRmU1k2T3AwNzZhNmYvWWVUSGNMWGpcbkVkVHBlUUtCZ0FPdnBqcDQ4TzRUWEZkU0JLSnYya005OHVhUjlSQURtdGxTWHd2cTlyQkhTV084NFk4bzE0L1Bcbkl1UmxUOHhROGRYKzhMR21UUCtjcUtiOFFRQ1grQk1YUWxMSEVtWnpnb0xFa0pGMUVIMm4vZEZ5bngxS3prdFNcbm9UZUdsRzZhbXhVOVh4eW9RVFlEVGJCbERwc2FZUlFBZ2FUQzM3UVZRUjhmK1ZoRzFHSFFcbi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tXG4iLCJhcHBJZCI6IjIwMjcwNjM5ODgxMzg4MDU3NyIsImNsaWVudElkIjoiMjAyNzA2Mzk4ODEzOTQ2MTEzQG15dGVzdHByb2plY3QifQ==\""; + } + ]; +} + +message DeleteApplicationKeyRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string project_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string application_id = 3 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string organization_id = 4 [(validate.rules).string = {max_len: 200}]; +} + +message DeleteApplicationKeyResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message GetApplicationKeyRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string project_id = 2 [(validate.rules).string = {max_len: 200}]; + string application_id = 3 [(validate.rules).string = {max_len: 200}]; + string organization_id = 4 [(validate.rules).string = {max_len: 200}]; +} + +message GetApplicationKeyResponse { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + // the date a key will expire + google.protobuf.Timestamp expiration_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the date a key will expire"; + example: "\"3019-04-01T08:45:00.000000Z\""; + } + ]; +} + +message ListApplicationKeysRequest { + // Pagination and sorting. + zitadel.filter.v2.PaginationRequest pagination = 1; + + ApplicationKeysSorting sorting_column = 2; + + oneof resource_id { + string application_id = 3 [(validate.rules).string = {min_len: 1; max_len: 200}]; + string project_id = 4 [(validate.rules).string = {min_len: 1; max_len: 200}]; + string organization_id = 5 [(validate.rules).string = {min_len: 1; max_len: 200}]; + } +} + +message ListApplicationKeysResponse { + repeated ApplicationKey keys = 1; + + // Contains the total number of app keys matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} diff --git a/proto/zitadel/app/v2beta/login.proto b/proto/zitadel/app/v2beta/login.proto new file mode 100644 index 0000000000..567b4b5167 --- /dev/null +++ b/proto/zitadel/app/v2beta/login.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message LoginVersion { + oneof version { + LoginV1 login_v1 = 1; + LoginV2 login_v2 = 2; + } +} + +message LoginV1 {} + +message LoginV2 { + // Optionally specify a base uri of the login UI. If unspecified the default URI will be used. + optional string base_uri = 1; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/oidc.proto b/proto/zitadel/app/v2beta/oidc.proto new file mode 100644 index 0000000000..7cfd1dcc43 --- /dev/null +++ b/proto/zitadel/app/v2beta/oidc.proto @@ -0,0 +1,166 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; +import "zitadel/app/v2beta/login.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/duration.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message OIDCLocalizedMessage { + string key = 1; + string localized_message = 2; +} + +enum OIDCResponseType { + OIDC_RESPONSE_TYPE_UNSPECIFIED = 0; + OIDC_RESPONSE_TYPE_CODE = 1; + OIDC_RESPONSE_TYPE_ID_TOKEN = 2; + OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN = 3; +} + +enum OIDCGrantType{ + OIDC_GRANT_TYPE_AUTHORIZATION_CODE = 0; + OIDC_GRANT_TYPE_IMPLICIT = 1; + OIDC_GRANT_TYPE_REFRESH_TOKEN = 2; + OIDC_GRANT_TYPE_DEVICE_CODE = 3; + OIDC_GRANT_TYPE_TOKEN_EXCHANGE = 4; +} + +enum OIDCAppType { + OIDC_APP_TYPE_WEB = 0; + OIDC_APP_TYPE_USER_AGENT = 1; + OIDC_APP_TYPE_NATIVE = 2; +} + +enum OIDCAuthMethodType { + OIDC_AUTH_METHOD_TYPE_BASIC = 0; + OIDC_AUTH_METHOD_TYPE_POST = 1; + OIDC_AUTH_METHOD_TYPE_NONE = 2; + OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT = 3; +} + +enum OIDCVersion { + OIDC_VERSION_1_0 = 0; +} + +enum OIDCTokenType { + OIDC_TOKEN_TYPE_BEARER = 0; + OIDC_TOKEN_TYPE_JWT = 1; +} + +message OIDCConfig { + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + OIDCAppType app_type = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "determines the paradigm of the application"; + } + ]; + string client_id = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334@ZITADEL\""; + description: "generated oauth2/oidc client id"; + } + ]; + OIDCAuthMethodType auth_method_type = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines how the application passes login credentials"; + } + ]; + repeated string post_logout_redirect_uris = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/logout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + OIDCVersion version = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the OIDC version used by the application"; + } + ]; + bool none_compliant = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "specifies whether the config is OIDC compliant. A production configuration SHOULD be compliant"; + } + ]; + repeated OIDCLocalizedMessage compliance_problems = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "lists the problems for non-compliancy"; + } + ]; + bool dev_mode = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "used for development"; + } + ]; + OIDCTokenType access_token_type = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "type of the access token returned from ZITADEL"; + } + ]; + bool access_token_role_assertion = 13 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + bool id_token_role_assertion = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + bool id_token_userinfo_assertion = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + google.protobuf.Duration clock_skew = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + // min: "0s"; + // max: "5s"; + } + ]; + repeated string additional_origins = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "additional origins (other than the redirect_uris) from where the API can be used"; + } + ]; + repeated string allowed_origins = 18 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "all allowed origins from where the API can be used"; + } + ]; + bool skip_native_app_success_page = 19 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + string back_channel_logout_uri = 20 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + LoginVersion login_version = 21 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/saml.proto b/proto/zitadel/app/v2beta/saml.proto new file mode 100644 index 0000000000..7c85447880 --- /dev/null +++ b/proto/zitadel/app/v2beta/saml.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "zitadel/app/v2beta/login.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message SAMLConfig { + oneof metadata{ + bytes metadata_xml = 1; + string metadata_url = 2; + } + LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto index 0ee6ad86d8..c995bce16a 100644 --- a/proto/zitadel/auth.proto +++ b/proto/zitadel/auth.proto @@ -859,6 +859,11 @@ service AuthService { }; } + // List My Authorizations / User Grants + // + // Deprecated: [List authorizations](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-list-authorizations.api.mdx) and pass the user ID filter with your users ID to search for your authorizations on granted and owned projects. + // + // Returns a list of the authorizations/user grants the authenticated user has. User grants consist of an organization, a project and 1-n roles. rpc ListMyUserGrants(ListMyUserGrantsRequest) returns (ListMyUserGrantsResponse) { option (google.api.http) = { post: "/usergrants/me/_search" @@ -869,9 +874,8 @@ service AuthService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "User Authorizations/Grants" - summary: "List My Authorizations/Grants"; - description: "Returns a list of the authorizations/user grants the authenticated user has. User grants consist of an organization, a project and 1-n roles." + tags: "User Authorizations/Grants"; + deprecated: true; }; } @@ -908,6 +912,11 @@ service AuthService { }; } + // List My Project Roles + // + // Deprecated: [List authorizations](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-list-authorizations.api.mdx) and pass the user ID filter with your users ID and the project ID filter to search for your authorizations on a granted and an owned project. + // + // Returns a list of roles for the authenticated user and for the requesting project. rpc ListMyProjectPermissions(ListMyProjectPermissionsRequest) returns (ListMyProjectPermissionsResponse) { option (google.api.http) = { post: "/permissions/me/_search" @@ -919,8 +928,7 @@ service AuthService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Authorizations/Grants" - summary: "List My Project Roles"; - description: "Returns a list of roles for the authenticated user and for the requesting project (based on the token)." + deprecated: true; }; } diff --git a/proto/zitadel/authorization/v2beta/authorization.proto b/proto/zitadel/authorization/v2beta/authorization.proto new file mode 100644 index 0000000000..aedd4c8b3c --- /dev/null +++ b/proto/zitadel/authorization/v2beta/authorization.proto @@ -0,0 +1,181 @@ +syntax = "proto3"; + +package zitadel.authorization.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; + +import "zitadel/filter/v2beta/filter.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta;authorization"; + +message Authorization { + // ID is the unique identifier of the authorization. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + + // ID is the unique identifier of the project the user was granted the authorization for. + string project_id = 2; + // Name is the name of the project the user was granted the authorization for. + string project_name = 3; + // OrganizationID is the ID of the organization the project belongs to. + string project_organization_id = 4; + // ID of the granted project, only provided if it is a granted project. + optional string project_grant_id = 5; + // ID of the organization the project is granted to, only provided if it is a granted project. + optional string granted_organization_id = 6; + + // The unique identifier of the organization the authorization belongs to. + string organization_id = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // CreationDate is the timestamp when the authorization was created. + google.protobuf.Timestamp creation_date = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // ChangeDate is the timestamp when the authorization was last updated. + // In case the authorization was not updated, this field is equal to the creation date. + google.protobuf.Timestamp change_date = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // State is the current state of the authorization. + State state = 10; + User user = 11; + // Roles contains the roles the user was granted for the project. + repeated string roles = 12; +} + +enum State { + STATE_UNSPECIFIED = 0; + // An active authorization grants the user access with the roles specified on the project. + STATE_ACTIVE = 1; + // An inactive authorization temporarily deactivates the granted access and roles. + // ZITADEL will not include the specific authorization in any authorization information like an access token. + // But the information can still be accessed using the API. + STATE_INACTIVE = 2; +} + +message User { + // ID represents the ID of the user who was granted the authorization. + string id = 1; + // PreferredLoginName represents the preferred login name of the granted user. + string preferred_login_name = 2; + // DisplayName represents the public display name of the granted user. + string display_name = 3; + // AvatarURL is the URL to the user's public avatar image. + string avatar_url = 4; + // The organization the user belong to. + // This does not have to correspond with the authorizations organization. + string organization_id = 5; +} + +message AuthorizationsSearchFilter { + oneof filter { + option (validate.required) = true; + + // Search for authorizations by their IDs. + zitadel.filter.v2beta.InIDsFilter authorization_ids = 1; + // Search for an organizations authorizations by its ID. + zitadel.filter.v2beta.IDFilter organization_id = 2; + // Search for authorizations by their state. + StateQuery state = 3; + // Search for authorizations by the ID of the user who was granted the authorization. + zitadel.filter.v2beta.IDFilter user_id = 4; + // Search for authorizations by the ID of the organisation the user is part of. + zitadel.filter.v2beta.IDFilter user_organization_id = 5; + // Search for authorizations by the preferred login name of the granted user. + UserPreferredLoginNameQuery user_preferred_login_name = 6; + // Search for authorizations by the public display name of the granted user. + UserDisplayNameQuery user_display_name = 7; + // Search for authorizations by the ID of the project the user was granted the authorization for. + // This will also include authorizations granted for project grants of the same project. + zitadel.filter.v2beta.IDFilter project_id = 8; + // Search for authorizations by the name of the project the user was granted the authorization for. + // This will also include authorizations granted for project grants of the same project. + ProjectNameQuery project_name = 9; + // Search for authorizations by the key of the role the user was granted. + RoleKeyQuery role_key = 10; + // Search for authorizations by the ID of the project grant the user was granted the authorization for. + // This will also include authorizations granted for project grants of the same project. + zitadel.filter.v2beta.IDFilter project_grant_id = 11; + } +} + +message StateQuery { + // Specify the state of the authorization to search for. + State state = 1 [(validate.rules).enum = {defined_only: true, not_in: [0]}]; +} + +message UserPreferredLoginNameQuery { + // Specify the preferred login name of the granted user to search for. + string login_name = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Specify the method to search for the preferred login name. Default is EQUAL. + // For example, to search for all authorizations granted to a user with + // a preferred login name containing a specific string, use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message UserDisplayNameQuery { + // Specify the public display name of the granted user to search for. + string display_name = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Specify the method to search for the display name. Default is EQUAL. + // For example, to search for all authorizations granted to a user with + // a display name containing a specific string, use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message ProjectNameQuery { + // Specify the name of the project the user was granted the authorization for to search for. + // Note that this will also include authorizations granted for project grants of the same project. + string name = 1 [(validate.rules).string = {max_len: 200}]; + // Specify the method to search for the project name. Default is EQUAL. + // For example, to search for all authorizations granted on a project with + // a name containing a specific string, use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message OrganizationNameQuery { + // Specify the name of the organization the authorization was granted for to search for. + // This can either be the organization the project or the project grant is part of. + string name = 1 [(validate.rules).string = {max_len: 200}]; + // Specify the method to search for the organization name. Default is EQUAL. + // For example, to search for all authorizations with an organization name containing a specific string, + // use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message RoleKeyQuery { + // Specify the key of the role the user was granted to search for. + string key = 1 [(validate.rules).string = {max_len: 200}]; + // Specify the method to search for the role key. Default is EQUAL. + // For example, to search for all authorizations starting with a specific role key, + // use STARTS_WITH or STARTS_WITH_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +enum AuthorizationFieldName { + AUTHORIZATION_FIELD_NAME_UNSPECIFIED = 0; + AUTHORIZATION_FIELD_NAME_CREATED_DATE = 1; + AUTHORIZATION_FIELD_NAME_CHANGED_DATE = 2; + AUTHORIZATION_FIELD_NAME_ID = 3; + AUTHORIZATION_FIELD_NAME_USER_ID = 4; + AUTHORIZATION_FIELD_NAME_PROJECT_ID = 5; + AUTHORIZATION_FIELD_NAME_ORGANIZATION_ID = 6; + AUTHORIZATION_FIELD_NAME_USER_ORGANIZATION_ID = 7; +} diff --git a/proto/zitadel/authorization/v2beta/authorization_service.proto b/proto/zitadel/authorization/v2beta/authorization_service.proto new file mode 100644 index 0000000000..5020154883 --- /dev/null +++ b/proto/zitadel/authorization/v2beta/authorization_service.proto @@ -0,0 +1,456 @@ +syntax = "proto3"; + +package zitadel.authorization.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; +import "google/api/annotations.proto"; + +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/authorization/v2beta/authorization.proto"; +import "zitadel/filter/v2beta/filter.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta;authorization"; + +// AuthorizationService provides methods to manage authorizations for users within your projects and applications. +// +// For managing permissions and roles for ZITADEL internal resources, like organizations, projects, +// users, etc., please use the InternalPermissionService. +service AuthorizationService { + + // List Authorizations + // + // ListAuthorizations returns all authorizations matching the request and necessary permissions. + // + // Required permissions: + // - "user.grant.read" + // - no permissions required for listing own authorizations + rpc ListAuthorizations(ListAuthorizationsRequest) returns (ListAuthorizationsResponse) { + option (google.api.http) = { + // The only reason why it is used here is to avoid a conflict with the ListUsers endpoint, which already handles POST /v2/users. + post: "/v2beta/authorizations/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all authorizations matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // Create Authorization + // + // CreateAuthorization creates a new authorization for a user in an owned or granted project. + // + // Required permissions: + // - "user.grant.write" + rpc CreateAuthorization(CreateAuthorizationRequest) returns (CreateAuthorizationResponse) { + option (google.api.http) = { + post: "/v2beta/authorizations" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The newly created authorization"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid create request"; + }; + }; + responses: { + key: "409" + value: { + description: "The authorization already exists."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Update Authorization + // + // UpdateAuthorization updates the authorization. + // + // Note that any role keys previously granted to the user and not present in the request will be revoked. + // + // Required permissions: + // - "user.grant.write" + rpc UpdateAuthorization(UpdateAuthorizationRequest) returns (UpdateAuthorizationResponse) { + option (google.api.http) = { + patch: "/v2beta/authorizations/{id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "OK"; + }; + }; + responses: { + key: "404"; + value: { + description: "Authorization or one of the roles do not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + } + }; + } + + // Delete Authorization + // + // DeleteAuthorization deletes the authorization. + // + // In case the authorization is not found, the request will return a successful response as + // the desired state is already achieved. + // You can check the deletion date in the response to verify if the authorization was deleted by the request. + // + // Required permissions: + // - "user.grant.delete" + rpc DeleteAuthorization(DeleteAuthorizationRequest) returns (DeleteAuthorizationResponse) { + option (google.api.http) = { + delete: "/v2beta/authorizations/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The authorization was deleted successfully."; + }; + }; + responses: { + key: "404"; + value: { + description: "Authorization not found."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } + + // Activate Authorization + // + // ActivateAuthorization activates an existing but inactive authorization. + // + // In case the authorization is already active, the request will return a successful response as + // the desired state is already achieved. + // You can check the change date in the response to verify if the authorization was activated by the request. + // + // Required permissions: + // - "user.grant.write" + rpc ActivateAuthorization(ActivateAuthorizationRequest) returns (ActivateAuthorizationResponse) { + option (google.api.http) = { + post: "/v2beta/authorizations/{id}/activate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The authorization was activated successfully."; + }; + }; + responses: { + key: "404"; + value: { + description: "Authorization not found."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } + + // Deactivate Authorization + // + // DeactivateAuthorization deactivates an existing and active authorization. + // + // In case the authorization is already inactive, the request will return a successful response as + // the desired state is already achieved. + // You can check the change date in the response to verify if the authorization was deactivated by the request. + // + // Required permissions: + // - "user.grant.write" + rpc DeactivateAuthorization(DeactivateAuthorizationRequest) returns (DeactivateAuthorizationResponse) { + option (google.api.http) = { + post: "/v2beta/authorizations/{id}/deactivate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The authorization was deactivated successfully."; + }; + }; + responses: { + key: "404"; + value: { + description: "Authorization not found."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } +} + +message ListAuthorizationsRequest { + // Paginate through the results using a limit, offset and sorting. + optional zitadel.filter.v2beta.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional AuthorizationFieldName sorting_column = 2 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"AUTHORIZATION_FIELD_NAME_CREATED_DATE\"" + } + ]; + // Define the criteria to query for. + repeated AuthorizationsSearchFilter filters = 3; +} + +message ListAuthorizationsResponse { + // Details contains the pagination information. + zitadel.filter.v2beta.PaginationResponse pagination = 1; + repeated Authorization authorizations = 2; +} + +message CreateAuthorizationRequest { + // UserID is the ID of the user who should be granted the authorization. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // Project ID is the ID of the project the user should be authorized for. + string project_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // OrganizationID is the ID of the organization on which the authorization should be created. + // The organization must either own the project or have a grant for the project. + // If omitted, the authorization is created on the projects organization. + optional string organization_id = 3 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // RoleKeys are the keys of the roles the user should be granted. + repeated string role_keys = 4 [ + (validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "[\"user\",\"admin\"]"; + } + ]; +} + +message CreateAuthorizationResponse { + // ID is the unique identifier of the newly created authorization. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // CreationDate is the timestamp when the authorization was created. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message UpdateAuthorizationRequest { + // ID is the unique identifier of the authorization. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // RoleKeys are the keys of the roles the user should be granted. + // Note that any role keys previously granted to the user and not present in the list will be revoked. + repeated string role_keys = 2 [ + (validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "[\"user\",\"admin\"]"; + } + ]; +} + +message UpdateAuthorizationResponse { + // ChangeDate is the timestamp when the authorization was last updated. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message DeleteAuthorizationRequest { + // ID is the unique identifier of the authorization that should be deleted. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; +} + +message DeleteAuthorizationResponse { + // DeletionDate is the timestamp when the authorization was deleted. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message ActivateAuthorizationRequest { + // ID is the unique identifier of the authorization that should be activated. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; +} + +message ActivateAuthorizationResponse { + // ChangeDate is the last timestamp when the authorization was changed / activated. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message DeactivateAuthorizationRequest { + // ID is the unique identifier of the authorization that should be deactivated. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; +} + +message DeactivateAuthorizationResponse { + // ChangeDate is the last timestamp when the authorization was changed / deactivated. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index fe8d3f7a39..f3467f723d 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -11,26 +11,14 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetInstanceFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 2, 3, 6, 8; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; description: "The login UI will use the settings of the default org (and not from the instance) if no organization context is set"; } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -55,13 +43,6 @@ message SetInstanceFeaturesRequest{ } ]; - optional bool web_key = 8 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - optional bool debug_oidc_parent_error = 9 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -131,8 +112,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 3, 4, 7, 9; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -141,20 +122,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -176,13 +143,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag web_key = 9 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - FeatureFlag debug_oidc_parent_error = 10 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2/system.proto b/proto/zitadel/feature/v2/system.proto index d222e2a90c..d3fbe6bccb 100644 --- a/proto/zitadel/feature/v2/system.proto +++ b/proto/zitadel/feature/v2/system.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetSystemFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 2, 3, 6; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -20,20 +20,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -105,8 +91,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 3, 4, 7; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -115,20 +101,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 7717dd7556..ac7a6c9286 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -11,26 +11,14 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetInstanceFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 2, 3, 6, 8; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; description: "The login UI will use the settings of the default org (and not from the instance) if no organization context is set"; } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -55,13 +43,6 @@ message SetInstanceFeaturesRequest{ } ]; - optional bool web_key = 8 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - optional bool debug_oidc_parent_error = 9 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -97,8 +78,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 3, 4, 7, 9; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -107,20 +88,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -142,13 +109,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag web_key = 9 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - FeatureFlag debug_oidc_parent_error = 10 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/system.proto b/proto/zitadel/feature/v2beta/system.proto index 624e68ec79..ae500eb87b 100644 --- a/proto/zitadel/feature/v2beta/system.proto +++ b/proto/zitadel/feature/v2beta/system.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetSystemFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 2, 3, 6; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -20,20 +20,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -78,8 +64,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 3, 4, 7; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -88,20 +74,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/filter/v2beta/filter.proto b/proto/zitadel/filter/v2beta/filter.proto index 2265fa4125..098808fed0 100644 --- a/proto/zitadel/filter/v2beta/filter.proto +++ b/proto/zitadel/filter/v2beta/filter.proto @@ -86,7 +86,18 @@ message TimestampFilter { message InIDsFilter { // Defines the ids to query for. repeated string ids = 1 [ + (validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; example: "[\"69629023906488334\",\"69622366012355662\"]"; } ]; diff --git a/proto/zitadel/internal_permission/v2beta/internal_permission_service.proto b/proto/zitadel/internal_permission/v2beta/internal_permission_service.proto new file mode 100644 index 0000000000..3a27b89a4f --- /dev/null +++ b/proto/zitadel/internal_permission/v2beta/internal_permission_service.proto @@ -0,0 +1,384 @@ +syntax = "proto3"; + +package zitadel.internal_permission.v2beta; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +import "google/protobuf/timestamp.proto"; +import "zitadel/filter/v2beta/filter.proto"; +import "zitadel/internal_permission/v2beta/query.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta;internal_permission"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Internal Permission Service"; + version: "2.0-beta"; + description: "This API is intended to manage internal permissions in ZITADEL. This service is in beta state. It can AND will continue breaking until a stable version is released."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + + +// InternalPermissionService provides methods to manage permissions for resource +// and their management in ZITADEL itself. +// +// If you want to manage permissions and roles within your project or application, +// please use the AuthorizationsService. +service InternalPermissionService { + // ListAdministrators returns all administrators and its roles matching the request and necessary permissions. + // + // Required permissions depend on the resource type: + // - "iam.member.read" for instance administrators + // - "org.member.read" for organization administrators + // - "project.member.read" for project administrators + // - "project.grant.member.read" for project grant administrators + // - no permissions required for listing own administrator roles + rpc ListAdministrators(ListAdministratorsRequest) returns (ListAdministratorsResponse) { + option (google.api.http) = { + post: "/v2beta/administrators/search", + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all administrators matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // CreateAdministrator grants a administrator role to a user for a specific resource. + // + // Note that the roles are specific to the resource type. + // This means that if you want to grant a user the administrator role for an organization and a project, + // you need to create two administrator roles. + // + // Required permissions depend on the resource type: + // - "iam.member.write" for instance administrators + // - "org.member.write" for organization administrators + // - "project.member.write" for project administrators + // - "project.grant.member.write" for project grant administrators + rpc CreateAdministrator(CreateAdministratorRequest) returns (CreateAdministratorResponse) { + option (google.api.http) = { + post: "/v2beta/administrators" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Administrator created successfully"; + }; + }; + responses: { + key: "409" + value: { + description: "The administrator to create already exists."; + } + }; + }; + } + + // UpdateAdministrator updates the specific administrator role. + // + // Note that any role previously granted to the user and not present in the request will be revoked. + // + // Required permissions depend on the resource type: + // - "iam.member.write" for instance administrators + // - "org.member.write" for organization administrators + // - "project.member.write" for project administrators + // - "project.grant.member.write" for project grant administrators + rpc UpdateAdministrator(UpdateAdministratorRequest) returns (UpdateAdministratorResponse) { + option (google.api.http) = { + post: "/v2beta/administrators/{user_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Administrator successfully updated or left unchanged"; + }; + }; + responses: { + key: "404" + value: { + description: "The administrator to update does not exist."; + } + }; + }; + } + + // DeleteAdministrator revokes a administrator role from a user. + // + // In case the administrator role is not found, the request will return a successful response as + // the desired state is already achieved. + // You can check the deletion date in the response to verify if the administrator role was deleted during the request. + // + // Required permissions depend on the resource type: + // - "iam.member.delete" for instance administrators + // - "org.member.delete" for organization administrators + // - "project.member.delete" for project administrators + // - "project.grant.member.delete" for project grant administrators + rpc DeleteAdministrator(DeleteAdministratorRequest) returns (DeleteAdministratorResponse) { + option (google.api.http) = { + delete: "/v2beta/administrators/{user_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Administrator deleted successfully"; + }; + }; + }; + } +} + +message ListAdministratorsRequest { + // List limitations and ordering. + optional zitadel.filter.v2beta.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional AdministratorFieldName sorting_column = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"ADMINISTRATOR_FIELD_NAME_CREATION_DATE\"" + } + ]; + // Filter the administrator roles to be returned. + repeated AdministratorSearchFilter filters = 3; +} + +message ListAdministratorsResponse { + zitadel.filter.v2beta.PaginationResponse pagination = 1; + repeated Administrator administrators = 2; +} + +message GetAdministratorRequest { + // ID is the unique identifier of the administrator. + string id = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; +} + +message GetAdministratorResponse { + Administrator administrator = 1; +} + +message CreateAdministratorRequest { + // UserID is the ID of the user who should be granted the administrator role. + string user_id = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Resource is the type of the resource the administrator roles should be granted for. + ResourceType resource = 2; + + // Roles are the roles that should be granted to the user for the specified resource. + // Note that roles are currently specific to the resource type. + // This means that if you want to grant a user the administrator role for an organization and a project, + // you need to create two administrator roles. + repeated string roles = 3 [(validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }]; +} + +message ResourceType { + message ProjectGrant { + // ProjectID is required to grant administrator privileges for a specific project. + string project_id = 1; + // ProjectGrantID is required to grant administrator privileges for a specific project grant. + string project_grant_id = 2; + } + + // Resource is the type of the resource the administrator roles should be granted for. + oneof resource { + option (validate.required) = true; + + // Instance is the resource type for granting administrator privileges on the instance level. + bool instance = 1 [(validate.rules).bool = {const: true}]; + // OrganizationID is required to grant administrator privileges for a specific organization. + string organization_id = 2; + // ProjectID is required to grant administrator privileges for a specific project. + string project_id = 3; + // ProjectGrantID is required to grant administrator privileges for a specific project grant. + ProjectGrant project_grant = 4; + } +} + +message CreateAdministratorResponse { + // CreationDate is the timestamp when the administrator role was created. + google.protobuf.Timestamp creation_date = 1; +} + +message UpdateAdministratorRequest { + // UserID is the ID of the user who should have his administrator roles update. + string user_id = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Resource is the type of the resource the administrator roles should be granted for. + ResourceType resource = 2; + + // Roles are the roles that the user should be granted. + // Note that any role previously granted to the user and not present in the list will be revoked. + repeated string roles = 3 [(validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }]; +} + +message UpdateAdministratorResponse { + // ChangeDate is the timestamp when the administrator role was last updated. + google.protobuf.Timestamp change_date = 1; +} + +message DeleteAdministratorRequest { + // UserID is the ID of the user who should have his administrator roles removed. + string user_id = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Resource is the type of the resource the administrator roles should be removed for. + ResourceType resource = 2; +} + +message DeleteAdministratorResponse { + // DeletionDate is the timestamp when the administrator role was deleted. + // Note that the deletion date is only guaranteed to be set if the deletion was successful during the request. + // In case the deletion occurred in a previous request, the deletion date might not be set. + google.protobuf.Timestamp deletion_date = 1; +} \ No newline at end of file diff --git a/proto/zitadel/internal_permission/v2beta/query.proto b/proto/zitadel/internal_permission/v2beta/query.proto new file mode 100644 index 0000000000..b23183cd50 --- /dev/null +++ b/proto/zitadel/internal_permission/v2beta/query.proto @@ -0,0 +1,166 @@ +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; +import "zitadel/filter/v2beta/filter.proto"; + +package zitadel.internal_permission.v2beta; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta;internal_permission"; + +message Administrator { + // CreationDate is the timestamp when the administrator role was granted. + google.protobuf.Timestamp creation_date = 1; + // ChangeDate is the timestamp when the administrator role was last updated. + // In case the administrator role was not updated, this field is equal to the creation date. + google.protobuf.Timestamp change_date = 2; + // User is the user who was granted the administrator role. + User user = 3; + // Resource is the type of the resource the administrator roles were granted for. + oneof resource { + // Instance is returned if the administrator roles were granted on the instance level. + bool instance = 4; + // Organization provides information about the organization the administrator roles were granted for. + Organization organization = 5; + // Project provides information about the project the administrator roles were granted for. + Project project = 6; + // ProjectGrant provides information about the project grant the administrator roles were granted for. + ProjectGrant project_grant = 7; + } + // Roles are the roles that were granted to the user for the specified resource. + repeated string roles = 8; +} + +message User { + // ID is the unique identifier of the user. + string id = 1; + // PreferredLoginName is the preferred login name of the user. This value is unique across the whole instance. + string preferred_login_name = 2; + // DisplayName is the public display name of the user. + // By default it's the user's given name and family name, their username or their email address. + string display_name = 3; + // The organization the user belong to. + string organization_id = 4; +} + +message Organization { + // ID is the unique identifier of the organization the user was granted the administrator role for. + string id = 1; + // Name is the name of the organization the user was granted the administrator role for. + string name = 2; +} +message Project { + // ID is the unique identifier of the project the user was granted the administrator role for. + string id = 1; + // Name is the name of the project the user was granted the administrator role for. + string name = 2; + // OrganizationID is the ID of the organization the project belongs to. + string organization_id = 3; +} +message ProjectGrant { + // ID is the unique identifier of the project grant the user was granted the administrator role for. + string id = 1; + // ProjectID is the ID of the project the project grant belongs to. + string project_id = 2; + // ProjectName is the name of the project the project grant belongs to. + string project_name = 3; + // OrganizationID is the ID of the organization the project grant belongs to. + string organization_id = 4; + // OrganizationID is the ID of the organization the project grant belongs to. + string granted_organization_id = 5; +} + +message AdministratorSearchFilter{ + oneof filter { + option (validate.required) = true; + // Search for administrator roles by their creation date. + zitadel.filter.v2beta.TimestampFilter creation_date = 1; + // Search for administrator roles by their change date. + zitadel.filter.v2beta.TimestampFilter change_date = 2; + // Search for administrators roles by the IDs of the users who was granted the administrator role. + zitadel.filter.v2beta.InIDsFilter in_user_ids_filter = 3; + // Search for administrators roles by the ID of the organization the user is part of. + zitadel.filter.v2beta.IDFilter user_organization_id = 4; + // Search for administrators roles by the preferred login name of the user. + UserPreferredLoginNameFilter user_preferred_login_name = 5; + // Search for administrators roles by the display name of the user. + UserDisplayNameFilter user_display_name = 6; + // Search for administrators roles granted for a specific resource. + ResourceFilter resource = 7; + // Search for administrators roles granted with a specific role. + RoleFilter role = 8; + + // Combine multiple authorization queries with an AND operation. + AndFilter and = 9; + // Combine multiple authorization queries with an OR operation. + // For example, to search for authorizations of multiple OrganizationIDs. + OrFilter or = 10; + // Negate an authorization query. + NotFilter not = 11; + } +} + +message UserPreferredLoginNameFilter { + // Search for administrators by the preferred login name of the user. + string preferred_login_name = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Specify the method to search for the preferred login name. Default is EQUAL. + // For example, to search for all administrator roles of a user with a preferred login name + // containing a specific string, use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message UserDisplayNameFilter { + // Search for administrators by the display name of the user. + string display_name = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Specify the method to search for the display name. Default is EQUAL. + // For example, to search for all administrator roles of a user with a display name + // containing a specific string, use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message ResourceFilter { + // Search for administrators by the granted resource. + oneof resource { + // Search for administrators granted on the instance level. + bool instance = 1; + // Search for administrators granted on a specific organization. + string organization_id = 2; + // Search for administrators granted on a specific project. + string project_id = 3; + // Search for administrators granted on a specific project grant. + string project_grant_id = 4; + } +} + +message RoleFilter { + // Search for administrators by the granted role. + string role_key = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; +} + +message AndFilter { + repeated AdministratorSearchFilter queries = 1; +} + +message OrFilter { + repeated AdministratorSearchFilter queries = 1; +} + +message NotFilter { + AdministratorSearchFilter query = 1; +} + +enum AdministratorFieldName { + ADMINISTRATOR_FIELD_NAME_UNSPECIFIED = 0; + ADMINISTRATOR_FIELD_NAME_USER_ID = 1; + ADMINISTRATOR_FIELD_NAME_CREATION_DATE = 2; + ADMINISTRATOR_FIELD_NAME_CHANGE_DATE = 3; +} diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 8cd0b22759..09095e3b78 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -493,8 +493,6 @@ service ManagementService { // Create User (Machine) // - // Deprecated: use [user service v2 CreateUser](apis/resources/user_service_v2/user-service-create-user.api.mdx) to create a user of type machine instead. - // // Create a new user with the type machine for your API, service or device. These users are used for non-interactive authentication flows. rpc AddMachineUser(AddMachineUserRequest) returns (AddMachineUserResponse) { option (google.api.http) = { @@ -507,7 +505,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -693,8 +690,6 @@ service ManagementService { // Change user name // - // Deprecated: use [user service v2 UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. - // // Change the username of the user. Be aware that the user has to log in with the newly added username afterward rpc UpdateUserName(UpdateUserNameRequest) returns (UpdateUserNameResponse) { option (google.api.http) = { @@ -708,7 +703,6 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Users"; - deprecated: true; responses: { key: "200" value: { @@ -1124,8 +1118,6 @@ service ManagementService { // Update User Phone (Human) // - // Deprecated: use [user service v2 SetPhone](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. - // // Change the phone number of a user. If the state is set to not verified, the user will get an SMS to verify (if a notification provider is configured). The phone number is only for informational purposes and to send messages, not for Authentication (2FA). rpc UpdateHumanPhone(UpdateHumanPhoneRequest) returns (UpdateHumanPhoneResponse) { option (google.api.http) = { @@ -1140,7 +1132,6 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Users"; tags: "User Human"; - deprecated: true; responses: { key: "200" value: { @@ -1656,8 +1647,6 @@ service ManagementService { // Update Machine User // - // Deprecated: use [user service v2 UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) to update a user of type machine instead. - // // Change a service account/machine user. It is used for accounts with non-interactive authentication possibilities. rpc UpdateMachine(UpdateMachineRequest) returns (UpdateMachineResponse) { option (google.api.http) = { @@ -1670,7 +1659,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1692,8 +1680,6 @@ service ManagementService { // Create Secret for Machine User // - // Deprecated: use [user service v2 AddSecret](apis/resources/user_service_v2/user-service-add-secret.api.mdx) instead. - // // Create a new secret for a machine user/service account. It is used to authenticate the user (client credential grant). rpc GenerateMachineSecret(GenerateMachineSecretRequest) returns (GenerateMachineSecretResponse) { option (google.api.http) = { @@ -1706,7 +1692,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1728,8 +1713,6 @@ service ManagementService { // Delete Secret of Machine User // - // Deprecated: use [user service v2 RemoveSecret](apis/resources/user_service_v2/user-service-remove-secret.api.mdx) instead. - // // Delete a secret of a machine user/service account. The user will not be able to authenticate with the secret afterward. rpc RemoveMachineSecret(RemoveMachineSecretRequest) returns (RemoveMachineSecretResponse) { option (google.api.http) = { @@ -1741,7 +1724,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1763,8 +1745,6 @@ service ManagementService { // Get Machine user Key By ID // - // Deprecated: use [user service v2 ListKeys](apis/resources/user_service_v2/user-service-list-keys.api.mdx) instead. - // // Get a specific Key of a machine user by its id. Machine keys are used to authenticate with jwt profile authentication. rpc GetMachineKeyByIDs(GetMachineKeyByIDsRequest) returns (GetMachineKeyByIDsResponse) { option (google.api.http) = { @@ -1776,7 +1756,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1796,9 +1775,7 @@ service ManagementService { }; } - // Get Machine user Key By ID - // - // Deprecated: use [user service v2 ListKeys](apis/resources/user_service_v2/user-service-list-keys.api.mdx) instead. + // List Machine Keys // // Get the list of keys of a machine user. Machine keys are used to authenticate with jwt profile authentication. rpc ListMachineKeys(ListMachineKeysRequest) returns (ListMachineKeysResponse) { @@ -1812,7 +1789,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1834,8 +1810,6 @@ service ManagementService { // Create Key for machine user // - // Deprecated: use [user service v2 AddKey](apis/resources/user_service_v2/user-service-add-key.api.mdx) instead. - // // If a public key is not supplied, a new key is generated and will be returned in the response. // Make sure to store the returned key. // If an RSA public key is supplied, the private key is omitted from the response. @@ -1851,7 +1825,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1873,8 +1846,6 @@ service ManagementService { // Delete Key for machine user // - // Deprecated: use [user service v2 RemoveKey](apis/resources/user_service_v2/user-service-remove-key.api.mdx) instead. - // // Delete a specific key from a user. // The user will not be able to authenticate with that key afterward. rpc RemoveMachineKey(RemoveMachineKeyRequest) returns (RemoveMachineKeyResponse) { @@ -1887,7 +1858,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1909,8 +1879,6 @@ service ManagementService { // Get Personal-Access-Token (PAT) by ID // - // Deprecated: use [user service v2 ListPersonalAccessTokens](apis/resources/user_service_v2/user-service-list-personal-access-tokens.api.mdx) instead. - // // Returns the PAT for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc GetPersonalAccessTokenByIDs(GetPersonalAccessTokenByIDsRequest) returns (GetPersonalAccessTokenByIDsResponse) { option (google.api.http) = { @@ -1922,7 +1890,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1944,8 +1911,6 @@ service ManagementService { // List Personal-Access-Tokens (PATs) // - // Deprecated: use [user service v2 ListPersonalAccessTokens](apis/resources/user_service_v2/user-service-list-personal-access-tokens.api.mdx) instead. - // // Returns a list of PATs for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { option (google.api.http) = { @@ -1958,7 +1923,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1980,8 +1944,6 @@ service ManagementService { // Create a Personal-Access-Token (PAT) // - // Deprecated: use [user service v2 AddPersonalAccessToken](apis/resources/user_service_v2/user-service-add-personal-access-token.api.mdx) instead. - // // Generates a new PAT for the user. Currently only available for machine users. // The token will be returned in the response, make sure to store it. // PATs are ready-to-use tokens and can be sent directly in the authentication header. @@ -1996,7 +1958,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -2018,8 +1979,6 @@ service ManagementService { // Remove a Personal-Access-Token (PAT) by ID // - // Deprecated: use [user service v2 RemovePersonalAccessToken](apis/resources/user_service_v2/user-service-remove-personal-access-token.api.mdx) instead. - // // Delete a PAT from a user. Afterward, the user will not be able to authenticate with that token anymore. rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { option (google.api.http) = { @@ -2031,7 +1990,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -3329,6 +3287,7 @@ service ManagementService { }; } + // Deprecated: Use [GetApplication](/apis/resources/application_service_v2/application-service-get-application.api.mdx) instead to fetch an app rpc GetAppByID(GetAppByIDRequest) returns (GetAppByIDResponse) { option (google.api.http) = { get: "/projects/{project_id}/apps/{app_id}" @@ -3351,9 +3310,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ListApplications](/apis/resources/application_service_v2/application-service-list-applications.api.mdx) instead to list applications rpc ListApps(ListAppsRequest) returns (ListAppsResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/_search" @@ -3377,6 +3338,7 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } @@ -3405,6 +3367,7 @@ service ManagementService { }; } + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create an OIDC application rpc AddOIDCApp(AddOIDCAppRequest) returns (AddOIDCAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/oidc" @@ -3428,62 +3391,74 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } - rpc AddSAMLApp(AddSAMLAppRequest) returns (AddSAMLAppResponse) { - option (google.api.http) = { - post: "/projects/{project_id}/apps/saml" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Create Application (SAML)"; - description: "Create a new SAML client. Returns an entity ID" - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; - } - - rpc AddAPIApp(AddAPIAppRequest) returns (AddAPIAppResponse) { - option (google.api.http) = { - post: "/projects/{project_id}/apps/api" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create a SAML application + rpc AddSAMLApp(AddSAMLAppRequest) returns (AddSAMLAppResponse) { + option (google.api.http) = { + post: "/projects/{project_id}/apps/saml" + body: "*" }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Create Application (API)"; - description: "Create a new API client. The client id will be generated and returned in the response. Depending on the chosen configuration also a secret will be generated and returned." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Create Application (SAML)"; + description: "Create a new SAML client. Returns an entity ID" + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; + } + + // Create Application (API) + // + // Create a new API client. The client id will be generated and returned in the response. + // Depending on the chosen configuration also a secret will be generated and returned. + // + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create an API application + rpc AddAPIApp(AddAPIAppRequest) returns (AddAPIAppResponse) { + option (google.api.http) = { + post: "/projects/{project_id}/apps/api" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Create Application (API)"; + description: "Create a new API client. The client id will be generated and returned in the response. Depending on the chosen configuration also a secret will be generated and returned." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; } // Changes application + // + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the generic params of an app rpc UpdateApp(UpdateAppRequest) returns (UpdateAppResponse) { option (google.api.http) = { put: "/projects/{project_id}/apps/{app_id}" @@ -3507,9 +3482,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of an OIDC app rpc UpdateOIDCAppConfig(UpdateOIDCAppConfigRequest) returns (UpdateOIDCAppConfigResponse) { option (google.api.http) = { put: "/projects/{project_id}/apps/{app_id}/oidc_config" @@ -3533,61 +3510,67 @@ service ManagementService { required: false; }; }; + deprecated: true }; } - rpc UpdateSAMLAppConfig(UpdateSAMLAppConfigRequest) returns (UpdateSAMLAppConfigResponse) { - option (google.api.http) = { - put: "/projects/{project_id}/apps/{app_id}/saml_config" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Update SAML Application Config"; - description: "Update the SAML specific configuration of an application." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; - } - - rpc UpdateAPIAppConfig(UpdateAPIAppConfigRequest) returns (UpdateAPIAppConfigResponse) { - option (google.api.http) = { - put: "/projects/{project_id}/apps/{app_id}/api_config" - body: "*" - }; + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of a SAML app + rpc UpdateSAMLAppConfig(UpdateSAMLAppConfigRequest) returns (UpdateSAMLAppConfigResponse) { + option (google.api.http) = { + put: "/projects/{project_id}/apps/{app_id}/saml_config" + body: "*" + }; option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" + permission: "project.app.write" + check_field_name: "ProjectId" }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Update API Application Config"; - description: "Update the OIDC-specific configuration of an application." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Update SAML Application Config"; + description: "Update the SAML specific configuration of an application." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; } + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of an API app + rpc UpdateAPIAppConfig(UpdateAPIAppConfigRequest) returns (UpdateAPIAppConfigResponse) { + option (google.api.http) = { + put: "/projects/{project_id}/apps/{app_id}/api_config" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Update API Application Config"; + description: "Update the OIDC-specific configuration of an application." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; + } + + // Deprecated: Use [DeactivateApplication](/apis/resources/application_service_v2/application-service-deactivate-application.api.mdx) instead to deactivate an app rpc DeactivateApp(DeactivateAppRequest) returns (DeactivateAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/_deactivate" @@ -3611,9 +3594,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ReactivateApplication](/apis/resources/application_service_v2/application-service-reactivate-application.api.mdx) instead to reactivate an app rpc ReactivateApp(ReactivateAppRequest) returns (ReactivateAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/_reactivate" @@ -3637,9 +3622,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [DeleteApplication](/apis/resources/application_service_v2/application-service-delete-application.api.mdx) instead to delete an app rpc RemoveApp(RemoveAppRequest) returns (RemoveAppResponse) { option (google.api.http) = { delete: "/projects/{project_id}/apps/{app_id}" @@ -3662,9 +3649,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [RegenerateClientSecret](/apis/resources/application_service_v2/application-service-regenerate-client-secret.api.mdx) instead to regenerate an OIDC app client secret rpc RegenerateOIDCClientSecret(RegenerateOIDCClientSecretRequest) returns (RegenerateOIDCClientSecretResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/oidc_config/_generate_client_secret" @@ -3688,9 +3677,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [RegenerateClientSecret](/apis/resources/application_service_v2/application-service-regenerate-client-secret.api.mdx) instead to regenerate an API app client secret rpc RegenerateAPIClientSecret(RegenerateAPIClientSecretRequest) returns (RegenerateAPIClientSecretResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/api_config/_generate_client_secret" @@ -3714,9 +3705,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [GetApplicationKey](/apis/resources/application_service_v2/application-service-get-application-key.api.mdx) instead to get an application key rpc GetAppKey(GetAppKeyRequest) returns (GetAppKeyResponse) { option (google.api.http) = { get: "/projects/{project_id}/apps/{app_id}/keys/{key_id}" @@ -3739,9 +3732,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ListApplicationKeys](/apis/resources/application_service_v2/application-service-list-application-keys.api.mdx) instead to list application keys rpc ListAppKeys(ListAppKeysRequest) returns (ListAppKeysResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/keys/_search" @@ -3768,6 +3763,8 @@ service ManagementService { }; } + // Deprecated: Use [CreateApplicationKey](/apis/resources/application_service_v2/application-service-create-application-key.api.mdx) instead to + // create an application key rpc AddAppKey(AddAppKeyRequest) returns (AddAppKeyResponse){ option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/keys" @@ -3791,9 +3788,12 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [DeleteApplicationKey](/apis/resources/application_service_v2/application-service-delete-application-key.api.mdx) instead to + // delete an application key rpc RemoveAppKey(RemoveAppKeyRequest) returns (RemoveAppKeyResponse) { option (google.api.http) = { delete: "/projects/{project_id}/apps/{app_id}/keys/{key_id}" @@ -3816,6 +3816,7 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } @@ -4176,6 +4177,11 @@ service ManagementService { }; } + // Get User Grant By ID + // + // Deprecated: [List authorizations](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-list-authorizations.api.mdx) and filter by its ID. + // + // Returns a user grant per ID. A user grant is a role a user has for a specific project and organization. rpc GetUserGrantByID(GetUserGrantByIDRequest) returns (GetUserGrantByIDResponse) { option (google.api.http) = { get: "/users/{user_id}/grants/{grant_id}" @@ -4187,8 +4193,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "User Grant By ID"; - description: "Returns a user grant per ID. A user grant is a role a user has for a specific project and organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4200,6 +4205,11 @@ service ManagementService { }; } + // Search User Grants + // + // Deprecated: [List authorizations](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-list-authorizations.api.mdx) and pass the user ID filter to search for a users grants on owned or granted projects. + // + // Returns a list of user grants that match the search queries. User grants are the roles users have for a specific project and organization. rpc ListUserGrants(ListUserGrantRequest) returns (ListUserGrantResponse) { option (google.api.http) = { post: "/users/grants/_search" @@ -4212,8 +4222,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Search User Grants"; - description: "Returns a list of user grants that match the search queries. User grants are the roles users have for a specific project and organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4225,6 +4234,12 @@ service ManagementService { }; } + + // Add User Grant + // + // Deprecated: [Add an authorization](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-create-authorization.api.mdx) to grant a user access to an owned or granted project. + // + // Add a user grant for a specific user. User grants are the roles users have for a specific project and organization. rpc AddUserGrant(AddUserGrantRequest) returns (AddUserGrantResponse) { option (google.api.http) = { post: "/users/{user_id}/grants" @@ -4237,8 +4252,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Add User Grant"; - description: "Add a user grant for a specific user. User grants are the roles users have for a specific project and organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4250,6 +4264,11 @@ service ManagementService { }; } + // Update User Grant + // + // Deprecated: [Update an authorization](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-update-authorization.api.mdx) to update a user's roles on an owned or granted project. + // + // Update the roles of a user grant. User grants are the roles users have for a specific project and organization. rpc UpdateUserGrant(UpdateUserGrantRequest) returns (UpdateUserGrantResponse) { option (google.api.http) = { put: "/users/{user_id}/grants/{grant_id}" @@ -4262,8 +4281,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Update User Grants"; - description: "Update the roles of a user grant. User grants are the roles users have for a specific project and organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4275,6 +4293,11 @@ service ManagementService { }; } + // Deactivate User Grant + // + // Deprecated: [Deactivate an authorization](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-deactivate-authorization.api.mdx) to disable a user's access to an owned or granted project. + // + // Deactivate the user grant. The user will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested. An error will be returned if the user grant is already deactivated. rpc DeactivateUserGrant(DeactivateUserGrantRequest) returns (DeactivateUserGrantResponse) { option (google.api.http) = { post: "/users/{user_id}/grants/{grant_id}/_deactivate" @@ -4287,8 +4310,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Deactivate User Grant"; - description: "Deactivate the user grant. The user will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested. An error will be returned if the user grant is already deactivated." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4300,6 +4322,11 @@ service ManagementService { }; } + // Reactivate User Grant + // + // Deprecated: [Activate an authorization](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-activate-authorization.api.mdx) to enable a user's access to an owned or granted project. + // + // Reactivate a deactivated user grant. The user will be able to use the granted project again. An error will be returned if the user grant is not deactivated. rpc ReactivateUserGrant(ReactivateUserGrantRequest) returns (ReactivateUserGrantResponse) { option (google.api.http) = { post: "/users/{user_id}/grants/{grant_id}/_reactivate" @@ -4312,8 +4339,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Reactivate User Grant"; - description: "Reactivate a deactivated user grant. The user will be able to use the granted project again. An error will be returned if the user grant is not deactivated." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4325,6 +4351,11 @@ service ManagementService { }; } + // Remove User Grant + // + // Deprecated: [Delete an authorization](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-delete-authorization.api.mdx) to remove a users access to an owned or granted project. + // + // Removes the user grant from the user. The user will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested. rpc RemoveUserGrant(RemoveUserGrantRequest) returns (RemoveUserGrantResponse) { option (google.api.http) = { delete: "/users/{user_id}/grants/{grant_id}" @@ -4336,8 +4367,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Remove User Grant"; - description: "Removes the user grant from the user. The user will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4349,6 +4379,11 @@ service ManagementService { }; } + // Bulk Remove User Grants + // + // Deprecated: [Delete authorizations one after the other](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-delete-authorization.api.mdx) to remove access for multiple users on multiple owned or granted projects. + // + // Remove a list of user grants. The users will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested. rpc BulkRemoveUserGrant(BulkRemoveUserGrantRequest) returns (BulkRemoveUserGrantResponse) { option (google.api.http) = { delete: "/user_grants/_bulk" @@ -4361,8 +4396,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Bulk Remove User Grants"; - description: "Remove a list of user grants. The users will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; diff --git a/proto/zitadel/metadata/v2/metadata.proto b/proto/zitadel/metadata/v2/metadata.proto new file mode 100644 index 0000000000..c04548ba4e --- /dev/null +++ b/proto/zitadel/metadata/v2/metadata.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +import "zitadel/filter/v2/filter.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +package zitadel.metadata.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/metadata/v2"; + +message Metadata { + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + string key = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "metadata key", + example: "\"key1\""; + } + ]; + bytes value = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "metadata value is base64 encoded, make sure to decode to get the value", + example: "\"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\""; + } + ]; +} + +message MetadataSearchFilter { + oneof filter { + option (validate.required) = true; + MetadataKeyFilter key_filter = 1; + } +} + +message MetadataKeyFilter { + string key = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"key\"" + } + ]; + zitadel.filter.v2.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/project/v2beta/project_service.proto b/proto/zitadel/project/v2beta/project_service.proto index cb7110bc91..66f221b911 100644 --- a/proto/zitadel/project/v2beta/project_service.proto +++ b/proto/zitadel/project/v2beta/project_service.proto @@ -451,7 +451,7 @@ service ProjectService { // - `project.role.read` rpc ListProjectRoles (ListProjectRolesRequest) returns (ListProjectRolesResponse) { option (google.api.http) = { - delete: "/v2beta/projects/{project_id}/roles/search" + post: "/v2beta/projects/{project_id}/roles/search" }; option (zitadel.protoc_gen_zitadel.v2.options) = { diff --git a/proto/zitadel/system.proto b/proto/zitadel/system.proto index 09b5559fb9..9b65fec600 100644 --- a/proto/zitadel/system.proto +++ b/proto/zitadel/system.proto @@ -118,7 +118,7 @@ service SystemService { // Returns a list of ZITADEL instances // - // Deprecated: Use [ListInstances](apis/resources/instance_service_v2/instance-service-list-instances.api.mdx) instead to list instances + // Deprecated: Use [ListInstances](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-list-instances.api.mdx) instead to list instances rpc ListInstances(ListInstancesRequest) returns (ListInstancesResponse) { option (google.api.http) = { post: "/instances/_search" @@ -136,7 +136,7 @@ service SystemService { // Returns the detail of an instance // - // Deprecated: Use [GetInstance](apis/resources/instance_service_v2/instance-service-get-instance.api.mdx) instead to get the details of the instance in context + // Deprecated: Use [GetInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-get-instance.api.mdx) instead to get the details of the instance in context rpc GetInstance(GetInstanceRequest) returns (GetInstanceResponse) { option (google.api.http) = { get: "/instances/{instance_id}"; @@ -171,7 +171,7 @@ service SystemService { // Updates name of an existing instance // - // Deprecated: Use [UpdateInstance](apis/resources/instance_service_v2/instance-service-update-instance.api.mdx) instead to update the name of the instance in context + // Deprecated: Use [UpdateInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-update-instance.api.mdx) instead to update the name of the instance in context rpc UpdateInstance(UpdateInstanceRequest) returns (UpdateInstanceResponse) { option (google.api.http) = { put: "/instances/{instance_id}" @@ -203,7 +203,7 @@ service SystemService { // Removes an instance // This might take some time // - // Deprecated: Use [DeleteInstance](apis/resources/instance_service_v2/instance-service-delete-instance.api.mdx) instead to delete an instance + // Deprecated: Use [DeleteInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-delete-instance.api.mdx) instead to delete an instance rpc RemoveInstance(RemoveInstanceRequest) returns (RemoveInstanceResponse) { option (google.api.http) = { delete: "/instances/{instance_id}" @@ -234,7 +234,7 @@ service SystemService { // Checks if a domain exists // - // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/instance-service-list-custom-domains.api.mdx) instead to check existence of an instance + // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-list-custom-domains.api.mdx) instead to check existence of an instance rpc ExistsDomain(ExistsDomainRequest) returns (ExistsDomainResponse) { option (google.api.http) = { post: "/domains/{domain}/_exists"; @@ -270,7 +270,7 @@ service SystemService { // Adds a domain to an instance // - // Deprecated: Use [AddCustomDomain](apis/resources/instance_service_v2/instance-service-add-custom-domain.api.mdx) instead to add a custom domain to the instance in context + // Deprecated: Use [AddCustomDomain](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-add-custom-domain.api.mdx) instead to add a custom domain to the instance in context rpc AddDomain(AddDomainRequest) returns (AddDomainResponse) { option (google.api.http) = { post: "/instances/{instance_id}/domains"; @@ -288,7 +288,7 @@ service SystemService { // Removes the domain of an instance // - // Deprecated: Use [RemoveDomain](apis/resources/instance_service_v2/instance-service-remove-custom-domain.api.mdx) instead to remove a custom domain from the instance in context + // Deprecated: Use [RemoveDomain](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-remove-custom-domain.api.mdx) instead to remove a custom domain from the instance in context rpc RemoveDomain(RemoveDomainRequest) returns (RemoveDomainResponse) { option (google.api.http) = { delete: "/instances/{instance_id}/domains/{domain}"; diff --git a/proto/zitadel/user/v2/idp.proto b/proto/zitadel/user/v2/idp.proto index 73e633fb67..828a035c29 100644 --- a/proto/zitadel/user/v2/idp.proto +++ b/proto/zitadel/user/v2/idp.proto @@ -162,3 +162,21 @@ message IDPLink { } ]; } + +message FormData { + // The URL to which the form should be submitted using the POST method. + string url = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://idp.com/saml/v2/acs\""; + } + ]; + // The form fields to be submitted. + // Each field is represented as a key-value pair, where the key is the field / input name + // and the value is the field / input value. + // All fields need to be submitted as is and as input type "text". + map fields = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"relayState\":\"state\",\"SAMLRequest\":\"asjfkj3ir2fj248=\"}"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index a416555905..7ed12f0143 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -22,6 +22,7 @@ import "zitadel/user/v2/key.proto"; import "zitadel/user/v2/pat.proto"; import "zitadel/user/v2/query.proto"; import "zitadel/filter/v2/filter.proto"; +import "zitadel/metadata/v2/metadata.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2;user"; @@ -133,13 +134,6 @@ service UserService { // Required permission: // - user.write rpc CreateUser (CreateUserRequest) returns (CreateUserResponse) { - option (google.api.http) = { - // The /new path segment does not follow Zitadels API design. - // The only reason why it is used here is to avoid a conflict with the ListUsers endpoint, which already handles POST /v2/users. - post: "/v2/users/new" - body: "*" - }; - option (zitadel.protoc_gen_zitadel.v2.options) = { auth_option: { permission: "authenticated" @@ -169,8 +163,6 @@ service UserService { // Create a new human user // - // Deprecated: Use [CreateUser](apis/resources/user_service_v2/user-service-create-user.api.mdx) to create a new user of type human instead. - // // Create/import a new user with the type human. The newly created user will get a verification email if either the email address is not marked as verified and you did not request the verification to be returned. rpc AddHumanUser (AddHumanUserRequest) returns (AddHumanUserResponse) { option (google.api.http) = { @@ -189,7 +181,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -270,8 +261,6 @@ service UserService { // Change the user email // - // Deprecated: [Update the users email field](apis/resources/user_service_v2/user-service-update-user.api.mdx). - // // Change the email address of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by email.. rpc SetEmail (SetEmailRequest) returns (SetEmailResponse) { option (google.api.http) = { @@ -286,7 +275,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -393,8 +381,6 @@ service UserService { // Set the user phone // - // Deprecated: [Update the users phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx). - // // Set the phone number of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by sms.. rpc SetPhone(SetPhoneRequest) returns (SetPhoneResponse) { option (google.api.http) = { @@ -409,7 +395,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -427,8 +412,6 @@ service UserService { // Delete the user phone // - // Deprecated: [Update the users phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx) to remove the phone number. - // // Delete the phone number of a user. rpc RemovePhone(RemovePhoneRequest) returns (RemovePhoneResponse) { option (google.api.http) = { @@ -443,7 +426,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -519,7 +501,6 @@ service UserService { }; } - // Update a User // // Partially update an existing user. @@ -529,10 +510,6 @@ service UserService { // Required permission: // - user.write rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) { - option (google.api.http) = { - patch: "/v2/users/{user_id}" - body: "*" - }; option (zitadel.protoc_gen_zitadel.v2.options) = { auth_option: { @@ -574,8 +551,6 @@ service UserService { // Update Human User // - // Deprecated: Use [UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) to update a user of type human instead. - // // Update all information from a user.. rpc UpdateHumanUser(UpdateHumanUserRequest) returns (UpdateHumanUserResponse) { option (google.api.http) = { @@ -590,7 +565,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -1378,8 +1352,6 @@ service UserService { // Change password // - // Deprecated: [Update the users password](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. - // // Change the password of a user with either a verification code or the current password.. rpc SetPassword (SetPasswordRequest) returns (SetPasswordResponse) { option (google.api.http) = { @@ -1394,7 +1366,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -1410,6 +1381,245 @@ service UserService { }; } + + // Add a Users Secret + // + // Generates a client secret for the user. + // The client id is the users username. + // If the user already has a secret, it is overwritten. + // Only users of type machine can have a secret. + // + // Required permission: + // - user.write + rpc AddSecret(AddSecretRequest) returns (AddSecretResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The secret was successfully generated."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Users Secret + // + // Remove the current client ID and client secret from a machine user. + // + // Required permission: + // - user.write + rpc RemoveSecret(RemoveSecretRequest) returns (RemoveSecretResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The secret was either successfully removed or it didn't exist in the first place."; + } + }; + }; + } + + // Add a Key + // + // Add a keys that can be used to securely authenticate at the Zitadel APIs using JWT profile authentication using short-lived tokens. + // Make sure you store the returned key safely, as you won't be able to read it from the Zitadel API anymore. + // Only users of type machine can have keys. + // + // Required permission: + // - user.write + rpc AddKey(AddKeyRequest) returns (AddKeyResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The key was successfully created."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Key + // + // Remove a machine users key by the given key ID and an optionally given user ID. + // + // Required permission: + // - user.write + rpc RemoveKey(RemoveKeyRequest) returns (RemoveKeyResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The key was either successfully removed or it not found in the first place."; + } + }; + }; + } + + // Search Keys + // + // List all matching keys. By default all keys of the instance on which the caller has permission to read the owning users are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - user.read + rpc ListKeys(ListKeysRequest) returns (ListKeysResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all machine user keys matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // Add a Personal Access Token + // + // Personal access tokens (PAT) are the easiest way to authenticate to the Zitadel APIs. + // Make sure you store the returned PAT safely, as you won't be able to read it from the Zitadel API anymore. + // Only users of type machine can have personal access tokens. + // + // Required permission: + // - user.write + rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The personal access token was successfully created."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Personal Access Token + // + // Removes a machine users personal access token by the given token ID and an optionally given user ID. + // + // Required permission: + // - user.write + rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The personal access token was either successfully removed or it was not found in the first place."; + } + }; + }; + } + + // Search Personal Access Tokens + // + // List all personal access tokens. By default all personal access tokens of the instance on which the caller has permission to read the owning users are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - user.read + rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all personal access tokens matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + // List all possible authentication methods of a user // // List all possible authentication methods of a user like password, passwordless, (T)OTP and more.. @@ -1584,154 +1794,15 @@ service UserService { } }; } - - // Add a Users Secret + // Set User Metadata // - // Generates a client secret for the user. - // The client id is the users username. - // If the user already has a secret, it is overwritten. - // Only users of type machine can have a secret. + // Sets a list of key value pairs. Existing metadata entries with matching keys are overwritten. Existing metadata entries without matching keys are untouched. To remove metadata entries, use [DeleteUserMetadata](apis/resources/user_service_v2/user-service-delete-user-metadata.api.mdx). For HTTP requests, make sure the bytes array value is base64 encoded. // // Required permission: - // - user.write - rpc AddSecret(AddSecretRequest) returns (AddSecretResponse) { + // - `user.write` + rpc SetUserMetadata(SetUserMetadataRequest) returns (SetUserMetadataResponse) { option (google.api.http) = { - post: "/v2/users/{user_id}/secret" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The secret was successfully generated."; - } - }; - responses: { - key: "404" - value: { - description: "The user ID does not exist."; - schema: { - json_schema: { - ref: "#/definitions/rpcStatus"; - }; - }; - } - }; - }; - } - - // Remove a Users Secret - // - // Remove the current client ID and client secret from a machine user. - // - // Required permission: - // - user.write - rpc RemoveSecret(RemoveSecretRequest) returns (RemoveSecretResponse) { - option (google.api.http) = { - delete: "/v2/users/{user_id}/secret" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The secret was either successfully removed or it didn't exist in the first place."; - } - }; - }; - } - - // Add a Key - // - // Add a keys that can be used to securely authenticate at the Zitadel APIs using JWT profile authentication using short-lived tokens. - // Make sure you store the returned key safely, as you won't be able to read it from the Zitadel API anymore. - // Only users of type machine can have keys. - // - // Required permission: - // - user.write - rpc AddKey(AddKeyRequest) returns (AddKeyResponse) { - option (google.api.http) = { - post: "/v2/users/{user_id}/keys" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The key was successfully created."; - } - }; - responses: { - key: "404" - value: { - description: "The user ID does not exist."; - schema: { - json_schema: { - ref: "#/definitions/rpcStatus"; - }; - }; - } - }; - }; - } - - // Remove a Key - // - // Remove a machine users key by the given key ID and an optionally given user ID. - // - // Required permission: - // - user.write - rpc RemoveKey(RemoveKeyRequest) returns (RemoveKeyResponse) { - option (google.api.http) = { - delete: "/v2/users/{user_id}/keys/{key_id}" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The key was either successfully removed or it not found in the first place."; - } - }; - }; - } - - // Search Keys - // - // List all matching keys. By default all keys of the instance on which the caller has permission to read the owning users are returned. - // Make sure to include a limit and sorting for pagination. - // - // Required permission: - // - user.read - rpc ListKeys(ListKeysRequest) returns (ListKeysResponse) { - option (google.api.http) = { - post: "/v2/users/keys/search" + post: "/v2/users/{user_id}/metadata" body: "*" }; @@ -1744,98 +1815,49 @@ service UserService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { responses: { key: "200"; - value: { - description: "A list of all machine user keys matching the query"; - }; }; responses: { - key: "400"; + key: "400" value: { - description: "invalid list query"; - }; + description: "User not found"; + } }; }; } - // Add a Personal Access Token + // List User Metadata // - // Personal access tokens (PAT) are the easiest way to authenticate to the Zitadel APIs. - // Make sure you store the returned PAT safely, as you won't be able to read it from the Zitadel API anymore. - // Only users of type machine can have personal access tokens. + // List metadata of an user filtered by query. // // Required permission: - // - user.write - rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { + // - `user.read` + rpc ListUserMetadata(ListUserMetadataRequest) returns (ListUserMetadataResponse) { option (google.api.http) = { - post: "/v2/users/{user_id}/pats" + post: "/v2/users/{user_id}/metadata/search" body: "*" }; - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } + option (zitadel.protoc_gen_zitadel.v2.options) = {auth_option: { + permission: "user.read" + } }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { responses: { - key: "200" - value: { - description: "The personal access token was successfully created."; - } - }; - responses: { - key: "404" - value: { - description: "The user ID does not exist."; - schema: { - json_schema: { - ref: "#/definitions/rpcStatus"; - }; - }; - } + key: "200"; }; }; } - // Remove a Personal Access Token + // Delete User Metadata // - // Removes a machine users personal access token by the given token ID and an optionally given user ID. + // Delete metadata objects from an user with a specific key. // // Required permission: - // - user.write - rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { + // - `user.write` + rpc DeleteUserMetadata(DeleteUserMetadataRequest) returns (DeleteUserMetadataResponse) { option (google.api.http) = { - delete: "/v2/users/{user_id}/pats/{token_id}" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The personal access token was either successfully removed or it was not found in the first place."; - } - }; - }; - } - - // Search Personal Access Tokens - // - // List all personal access tokens. By default all personal access tokens of the instance on which the caller has permission to read the owning users are returned. - // Make sure to include a limit and sorting for pagination. - // - // Required permission: - // - user.read - rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { - option (google.api.http) = { - post: "/v2/users/pats/search" - body: "*" + delete: "/v2/users/{user_id}/metadata" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -1847,15 +1869,6 @@ service UserService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { responses: { key: "200"; - value: { - description: "A list of all personal access tokens matching the query"; - }; - }; - responses: { - key: "400"; - value: { - description: "invalid list query"; - }; }; }; } @@ -1956,6 +1969,13 @@ message CreateUserRequest{ example: "\"TJOPWSDYILLHXFV4MLKNNJOWFG7VSDCK\""; } ]; + + // Metadata to bet set. The values have to be base64 encoded. + repeated Metadata metadata = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[{\"key\": \"test1\", \"value\": \"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\"}, {\"key\": \"test2\", \"value\": \"VGhpcyBpcyBteSBzZWNvbmQgdmFsdWU=\"}]" + } + ]; } message Machine { // The machine users name is a human readable field that helps identifying the user. @@ -2961,11 +2981,15 @@ message StartIdentityProviderIntentResponse{ description: "IDP Intent information" } ]; + // POST call information + // Deprecated: Use form_data instead bytes post_form = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { description: "POST call information" } ]; + // Data for a form POST call + FormData form_data = 5; } } @@ -3518,3 +3542,79 @@ message ListPersonalAccessTokensResponse { zitadel.filter.v2.PaginationResponse pagination = 1; repeated PersonalAccessToken result = 2; } + +message Metadata { + // Key in the metadata key/value pair. + string key = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + // Value in the metadata key/value pair. + bytes value = 2 [(validate.rules).bytes = {min_len: 1, max_len: 500000}]; +} + +message SetUserMetadataRequest{ + // ID of the user under which the metadata gets set. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + // Metadata to bet set. The values have to be base64 encoded. + repeated Metadata metadata = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[{\"key\": \"test1\", \"value\": \"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\"}, {\"key\": \"test2\", \"value\": \"VGhpcyBpcyBteSBzZWNvbmQgdmFsdWU=\"}]" + } + ]; +} + +message SetUserMetadataResponse{ + // The timestamp of the update of the user metadata. + google.protobuf.Timestamp set_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListUserMetadataRequest { + // ID of the user under which the metadata is to be listed. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + + // List limitations and ordering. + optional zitadel.filter.v2.PaginationRequest pagination = 2; + // Define the criteria to query for. + repeated zitadel.metadata.v2.MetadataSearchFilter filters = 3; +} + +message ListUserMetadataResponse { + // Pagination of the users metadata results. + zitadel.filter.v2.PaginationResponse pagination = 1; + // The user metadata requested. + repeated zitadel.metadata.v2.Metadata metadata = 2; +} + +message DeleteUserMetadataRequest { + // ID of the user which metadata is to be deleted is stored on. + string user_id = 1; + // The keys for the user metadata to be deleted. + repeated string keys = 2 [(validate.rules).repeated.items.string = {min_len: 1, max_len: 200}]; +} + +message DeleteUserMetadataResponse{ + // The timestamp of the deletion of the user metadata. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/user/v2beta/idp.proto b/proto/zitadel/user/v2beta/idp.proto index 7d58ec5363..237c8de114 100644 --- a/proto/zitadel/user/v2beta/idp.proto +++ b/proto/zitadel/user/v2beta/idp.proto @@ -162,3 +162,21 @@ message IDPLink { } ]; } + +message FormData { + // The URL to which the form should be submitted using the POST method. + string url = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://idp.com/saml/v2/acs\""; + } + ]; + // The form fields to be submitted. + // Each field is represented as a key-value pair, where the key is the field / input name + // and the value is the field / input value. + // All fields need to be submitted as is and as input type "text". + map fields = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"relayState\":\"state\",\"SAMLRequest\":\"asjfkj3ir2fj248=\"}"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/user/v2beta/user_service.proto b/proto/zitadel/user/v2beta/user_service.proto index f877252f51..bcb091abf2 100644 --- a/proto/zitadel/user/v2beta/user_service.proto +++ b/proto/zitadel/user/v2beta/user_service.proto @@ -1788,22 +1788,23 @@ message StartIdentityProviderIntentRequest{ message StartIdentityProviderIntentResponse{ zitadel.object.v2beta.Details details = 1; oneof next_step { + // URL to which the client should redirect string auth_url = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "URL to which the client should redirect" example: "\"https://accounts.google.com/o/oauth2/v2/auth?client_id=clientID&callback=https%3A%2F%2Fzitadel.cloud%2Fidps%2Fcallback\""; } ]; - IDPIntent idp_intent = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "IDP Intent information" - } - ]; + // IDP Intent information + IDPIntent idp_intent = 3; + // POST call information + // Deprecated: Use form_data instead bytes post_form = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { description: "POST call information" } ]; + // Data for a form POST call + FormData form_data = 5; } } diff --git a/proto/zitadel/webkey/v2/key.proto b/proto/zitadel/webkey/v2/key.proto new file mode 100644 index 0000000000..4ec85fa168 --- /dev/null +++ b/proto/zitadel/webkey/v2/key.proto @@ -0,0 +1,109 @@ +syntax = "proto3"; + +package zitadel.webkey.v2; + +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/webkey/v2;webkey"; + +enum State { + STATE_UNSPECIFIED = 0; + // A newly created key is in the initial state and published to the public key endpoint. + STATE_INITIAL = 1; + // The active key is used to sign tokens. Only one key can be active at a time. + STATE_ACTIVE = 2; + // The inactive key is not used to sign tokens anymore, but still published to the public key endpoint. + STATE_INACTIVE = 3; + // The removed key is not used to sign tokens anymore and not published to the public key endpoint. + STATE_REMOVED = 4; +} + +message WebKey { + // The unique identifier of the key. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the key creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change to the key (e.g. creation, activation, deactivation). + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // State of the key + State state = 4; + // Configured type of the key (either RSA, ECDSA or ED25519) + oneof key { + RSA rsa = 5; + ECDSA ecdsa = 6; + ED25519 ed25519 = 7; + } +} + +message RSA { + // Bit size of the RSA key. Default is 2048 bits. + RSABits bits = 1 [ + (validate.rules).enum = {defined_only: true, not_in: [0]}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "RSA_BITS_2048"; + } + ]; + // Signing algrithm used. Default is SHA256. + RSAHasher hasher = 2 [ + (validate.rules).enum = {defined_only: true, not_in: [0]}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "RSA_HASHER_SHA256"; + } + ]; +} + +enum RSABits { + RSA_BITS_UNSPECIFIED = 0; + // 2048 bit RSA key + RSA_BITS_2048 = 1; + // 3072 bit RSA key + RSA_BITS_3072 = 2; + // 4096 bit RSA key + RSA_BITS_4096 = 3; +} + +enum RSAHasher { + RSA_HASHER_UNSPECIFIED = 0; + // SHA256 hashing algorithm resulting in the RS256 algorithm header + RSA_HASHER_SHA256 = 1; + // SHA384 hashing algorithm resulting in the RS384 algorithm header + RSA_HASHER_SHA384 = 2; + // SHA512 hashing algorithm resulting in the RS512 algorithm header + RSA_HASHER_SHA512 = 3; +} + +message ECDSA { + // Curve of the ECDSA key. Default is P-256. + ECDSACurve curve = 1 [ + (validate.rules).enum = {defined_only: true, not_in: [0]}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "ECDSA_CURVE_P256"; + } + ]; +} + +enum ECDSACurve { + ECDSA_CURVE_UNSPECIFIED = 0; + // NIST P-256 curve resulting in the ES256 algorithm header + ECDSA_CURVE_P256 = 1; + // NIST P-384 curve resulting in the ES384 algorithm header + ECDSA_CURVE_P384 = 2; + // NIST P-512 curve resulting in the ES512 algorithm header + ECDSA_CURVE_P512 = 3; +} + +message ED25519 {} diff --git a/proto/zitadel/webkey/v2/webkey_service.proto b/proto/zitadel/webkey/v2/webkey_service.proto new file mode 100644 index 0000000000..f29f291c38 --- /dev/null +++ b/proto/zitadel/webkey/v2/webkey_service.proto @@ -0,0 +1,335 @@ +syntax = "proto3"; + +package zitadel.webkey.v2; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/webkey/v2/key.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/webkey/v2;webkey"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Web Key Service"; + version: "2.0"; + description: "This API is intended to manage web keys for a ZITADEL instance, used to sign and validate OIDC tokens.\n\nThe public key endpoint (outside of this service) is used to retrieve the public keys of the active and inactive keys.\n\nPlease make sure to enable the `web_key` feature flag on your instance to use this service."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + produces: "application/json"; + + consumes: "application/grpc"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// Service to manage web keys for OIDC token signing and validation. +// The service provides methods to create, activate, delete and list web keys. +// The public key endpoint (outside of this service) is used to retrieve the public keys of the active and inactive keys. +// +// Please make sure to enable the `web_key` feature flag on your instance to use this service. +service WebKeyService { + // Create Web Key + // + // Generate a private and public key pair. The private key can be used to sign OIDC tokens after activation. + // The public key can be used to validate OIDC tokens. + // The newly created key will have the state `STATE_INITIAL` and is published to the public key endpoint. + // Note that the JWKs OIDC endpoint returns a cacheable response. + // + // If no key type is provided, a RSA key pair with 2048 bits and SHA256 hashing will be created. + // + // Required permission: + // - `iam.web_key.write` + // + // Required feature flag: + // - `web_key` + rpc CreateWebKey(CreateWebKeyRequest) returns (CreateWebKeyResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.write" + } + }; + } + + // Activate Web Key + // + // Switch the active signing web key. The previously active key will be deactivated. + // Note that the JWKs OIDC endpoint returns a cacheable response. + // Therefore it is not advised to activate a key that has been created within the cache duration (default is 5min), + // as the public key may not have been propagated to caches and clients yet. + // + // Required permission: + // - `iam.web_key.write` + // + // Required feature flag: + // - `web_key` + rpc ActivateWebKey(ActivateWebKeyRequest) returns (ActivateWebKeyResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Web key activated successfully."; + } + }; + responses: { + key: "400" + value: { + description: "The feature flag `web_key` is not enabled."; + } + }; + responses: { + key: "404" + value: { + description: "The web key to active does not exist."; + } + }; + }; + } + + // Delete Web Key + // + // Delete a web key pair. Only inactive keys can be deleted. Once a key is deleted, + // any tokens signed by this key will be invalid. + // Note that the JWKs OIDC endpoint returns a cacheable response. + // In case the web key is not found, the request will return a successful response as + // the desired state is already achieved. + // You can check the change date in the response to verify if the web key was deleted during the request. + // + // Required permission: + // - `iam.web_key.delete` + // + // Required feature flag: + // - `web_key` + rpc DeleteWebKey(DeleteWebKeyRequest) returns (DeleteWebKeyResponse) { + option (google.api.http) = { + delete: "/v2/web_keys/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.delete" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Web key deleted successfully."; + } + }; + responses: { + key: "400" + value: { + description: "The feature flag `web_key` is not enabled or the web key is currently active."; + } + }; + }; + } + + // List Web Keys + // + // List all web keys and their states. + // + // Required permission: + // - `iam.web_key.read` + // + // Required feature flag: + // - `web_key` + rpc ListWebKeys(ListWebKeysRequest) returns (ListWebKeysResponse) { + option (google.api.http) = { + get: "/v2/web_keys" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "List of all web keys."; + } + }; + responses: { + key: "400" + value: { + description: "The feature flag `web_key` is not enabled."; + } + }; + }; + } +} + +message CreateWebKeyRequest { + // The key type to create (RSA, ECDSA, ED25519). + // If no key type is provided, a RSA key pair with 2048 bits and SHA256 hashing will be created. + oneof key { + // Create a RSA key pair and specify the bit size and hashing algorithm. + // If no bits and hasher are provided, a RSA key pair with 2048 bits and SHA256 hashing will be created. + RSA rsa = 1; + // Create a ECDSA key pair and specify the curve. + // If no curve is provided, a ECDSA key pair with P-256 curve will be created. + ECDSA ecdsa = 2; + // Create a ED25519 key pair. + ED25519 ed25519 = 3; + } + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"rsa\":{\"bits\":\"RSA_BITS_2048\",\"hasher\":\"RSA_HASHER_SHA256\"}}"; + }; +} + +message CreateWebKeyResponse { + // The unique identifier of the newly created key. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the key creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message ActivateWebKeyRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message ActivateWebKeyResponse { + // The timestamp of the activation of the key. + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeleteWebKeyRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message DeleteWebKeyResponse { + // The timestamp of the deletion of the key. + // Note that the deletion date is only guaranteed to be set if the deletion was successful during the request. + // In case the deletion occurred in a previous request, the deletion date might be empty. + google.protobuf.Timestamp deletion_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListWebKeysRequest {} + +message ListWebKeysResponse { + repeated WebKey web_keys = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[{\"id\":\"69629012906488334\",\"creationDate\":\"2024-12-18T07:50:47.492Z\",\"changeDate\":\"2024-12-18T08:04:47.492Z\",\"state\":\"STATE_ACTIVE\",\"rsa\":{\"bits\":\"RSA_BITS_2048\",\"hasher\":\"RSA_HASHER_SHA256\"}},{\"id\":\"69629012909346200\",\"creationDate\":\"2025-01-18T12:05:47.492Z\",\"state\":\"STATE_INITIAL\",\"ecdsa\":{\"curve\":\"ECDSA_CURVE_P256\"}}]"; + } + ]; +} \ No newline at end of file