Merge branch 'main' into dependabot/npm_and_yarn/docs/follow-redirects-1.15.6

This commit is contained in:
Florian Forster 2024-07-30 15:13:27 +02:00 committed by GitHub
commit fc3ab7d8cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1538 changed files with 110266 additions and 33654 deletions

View File

@ -5,7 +5,7 @@
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": {
"ghcr.io/devcontainers/features/go:1": {
"version": "1.21"
"version": "1.22"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "18"

View File

@ -7,7 +7,17 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
network_mode: service:db
command: sleep infinity
environment:
- 'ZITADEL_DATABASE_POSTGRES_HOST=db'
- 'ZITADEL_DATABASE_POSTGRES_PORT=5432'
- 'ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel'
- 'ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel'
- 'ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel'
- 'ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable'
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=postgres'
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD=postgres'
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable'
- 'ZITADEL_EXTERNALSECURE=false'
db:
image: postgres:latest
restart: unless-stopped

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf

View File

@ -1,15 +1,27 @@
### Definition of Ready
# Which Problems Are Solved
- [ ] 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.
- [ ] No debug or dead code
- [ ] My code has no repetitions
- [ ] Critical parts are tested automatically
- [ ] Where possible E2E tests are implemented
- [ ] Documentation/examples are up-to-date
- [ ] All non-functional requirements are met
- [ ] Functionality of the acceptance criteria is checked manually on the dev system.
Replace this example text with a concise list of problems that this PR solves.
For example:
- If the property XY is not given, the system crashes with a nil pointer exception.
# How the Problems Are Solved
Replace this example text with a concise list of changes that this PR introduces.
For example:
- Validates if property XY is given and throws an error if not
# Additional Changes
Replace this example text with a concise list of additional changes that this PR introduces, that are not directly solving the initial problem but are related.
For example:
- The docs explicitly describe that the property XY is mandatory
- Adds missing translations for validations.
# Additional Context
Replace this example with links to related issues, discussions, discord threads, or other sources with more context.
Use the Closing #issue syntax for issues that are resolved with this PR.
- Closes #123
- Discussion #456
- Follow-up for PR #789
- https://discord.com/channels/123/456

View File

@ -1,9 +1,18 @@
name: ZITADEL CI/CD
on:
push:
tags-ignore:
- "*"
branches:
- "main"
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: write
packages: write
@ -16,7 +25,7 @@ jobs:
with:
node_version: "20"
buf_version: "latest"
go_version: "1.21"
go_version: "1.22"
console:
uses: ./.github/workflows/console.yml
@ -27,14 +36,14 @@ jobs:
version:
uses: ./.github/workflows/version.yml
with:
semantic_version: "19.0.2"
semantic_version: "23.0.7"
dry_run: true
compile:
needs: [core, console, version]
uses: ./.github/workflows/compile.yml
with:
go_version: "1.21"
go_version: "1.22"
core_cache_key: ${{ needs.core.outputs.cache_key }}
console_cache_key: ${{ needs.console.outputs.cache_key }}
core_cache_path: ${{ needs.core.outputs.cache_path }}
@ -45,15 +54,17 @@ jobs:
needs: core
uses: ./.github/workflows/core-test.yml
with:
go_version: "1.21"
go_version: "1.22"
core_cache_key: ${{ needs.core.outputs.cache_key }}
core_cache_path: ${{ needs.core.outputs.cache_path }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
lint:
needs: [core, console]
uses: ./.github/workflows/lint.yml
with:
go_version: "1.21"
go_version: "1.22"
node_version: "18"
buf_version: "latest"
go_lint_version: "v1.55.2"
@ -86,8 +97,10 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' }}
secrets:
GCR_JSON_KEY_BASE64: ${{ secrets.GCR_JSON_KEY_BASE64 }}
APP_ID: ${{ secrets.APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
with:
build_image_name: ${{ needs.container.outputs.build_image }}
semantic_version: "19.0.2"
semantic_version: "23.0.7"
image_name: "ghcr.io/zitadel/zitadel"
google_image_name: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel"

View File

@ -13,6 +13,9 @@ on:
paths-ignore:
- 'docs/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
CodeQL-Build:

View File

@ -12,6 +12,9 @@ on:
core_cache_path:
required: true
type: string
secrets:
CODECOV_TOKEN:
required: true
jobs:
postgres:
@ -76,7 +79,7 @@ jobs:
run: make core_integration_test
-
name: publish coverage
uses: codecov/codecov-action@v4.1.0
uses: codecov/codecov-action@v4.3.0
with:
file: profile.cov
name: core-integration-tests-postgres
@ -145,7 +148,7 @@ jobs:
# run: make core_integration_test
# -
# name: publish coverage
# uses: codecov/codecov-action@v4.1.0
# uses: codecov/codecov-action@v4.3.0
# with:
# file: profile.cov
# name: core-integration-tests-cockroach

View File

@ -5,6 +5,7 @@ on:
jobs:
test:
timeout-minutes: 10
strategy:
fail-fast: false
matrix:

View File

@ -1,74 +0,0 @@
name: ZITADEL Update Homebrew Formula
on:
release:
types: [published]
jobs:
homebrew-releaser:
runs-on: ubuntu-latest
name: homebrew-releaser
steps:
- name: Release my project to my Homebrew tap
uses: Justintime50/homebrew-releaser@v1
with:
# The name of the homebrew tap to publish your formula to as it appears on GitHub.
# Required - strings
homebrew_owner: zitadel
homebrew_tap: homebrew-tap
# The name of the folder in your homebrew tap where formula will be committed to.
# Default is shown - string
formula_folder: Formula
# The Personal Access Token (saved as a repo secret) that has `repo` permissions for the repo running the action AND Homebrew tap you want to release to.
# Required - string
github_token: ${{ secrets.CR_PAT }}
# Git author info used to commit to the homebrew tap.
# Defaults are shown - strings
commit_owner: homebrew-releaser
commit_email: homebrew-releaser@zitadel.com
# Custom dependencies in case other formulas are needed to build the current one.
# Optional - multiline string
depends_on: |
"go" => :optional
"git"
# Custom install command for your formula.
# Required - string
install: 'bin.install "zitadel"'
# Custom test command for your formula so you can run `brew test`.
# Optional - string
test: 'ystem "#{bin}/zitadel -v"'
# Allows you to set a custom download strategy. Note that you'll need
# to implement the strategy and add it to your tap repository.
# Example: https://docs.brew.sh/Formula-Cookbook#specifying-the-download-strategy-explicitly
# Optional - string
# download_strategy: CurlDownloadStrategy
# Allows you to add a custom require_relative at the top of the formula template.
# Optional - string
# custom_require: custom_download_strategy
# Adds URL and checksum targets for different OS and architecture pairs. Using this option assumes
# a tar archive exists on your GitHub repo with the following URL pattern (this cannot be customized):
# https://github.com/{GITHUB_OWNER}/{REPO_NAME}/releases/download/{TAG}/{REPO_NAME}-{VERSION}-{OPERATING_SYSTEM}-{ARCHITECTURE}.tar.gz'
# Darwin AMD pre-existing path example: https://github.com/justintime50/myrepo/releases/download/v1.2.0/myrepo-1.2.0-darwin-amd64.tar.gz
# Linux ARM pre-existing path example: https://github.com/justintime50/myrepo/releases/download/v1.2.0/myrepo-1.2.0-linux-arm64.tar.gz
# Optional - booleans
target_darwin_amd64: true
target_darwin_arm64: true
target_linux_amd64: true
target_linux_arm64: true
# Skips committing the generated formula to a homebrew tap (useful for local testing).
# Default is shown - boolean
skip_commit: true
# Logs debugging info to console.
# Default is shown - boolean
debug: true

View File

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: add issue
uses: actions/add-to-project@v0.6.0
uses: actions/add-to-project@v1.0.1
if: ${{ github.event_name == 'issues' }}
with:
# You can target a repository in a different organization
@ -28,7 +28,7 @@ jobs:
username: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }}
- name: add pr
uses: actions/add-to-project@v0.6.0
uses: actions/add-to-project@v1.0.1
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

View File

@ -95,10 +95,8 @@ jobs:
key: ${{ inputs.core_cache_key }}
fail-on-cache-miss: true
-
uses: golangci/golangci-lint-action@v4
uses: golangci/golangci-lint-action@v6
with:
version: ${{ inputs.go_lint_version }}
github-token: ${{ github.token }}
only-new-issues: true
skip-pkg-cache: true
skip-build-cache: true

31
.github/workflows/ready_for_review.yml vendored Normal file
View File

@ -0,0 +1,31 @@
on:
pull_request:
types: [opened]
jobs:
comment:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const content = `### Thanks for your contribution @${{ github.event.pull_request.user.login }}! 🎉
Please make sure you tick the following checkboxes before marking this Pull Request (PR) as ready for review:
- [ ] I am happy with the code
- [ ] Documentations and examples are up-to-date
- [ ] Logical behavior changes are tested automatically
- [ ] No debug or dead code
- [ ] My code has no repetitions
- [ ] The PR title adheres to the [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] The example texts in the PR description are replaced.
- [ ] If there are any open TODOs or follow-ups, they are described in issues and link to this PR
- [ ] If there are deviations from a user stories acceptance criteria or design, they are agreed upon with the PO and documented.
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: content
})

View File

@ -19,6 +19,12 @@ on:
GCR_JSON_KEY_BASE64:
description: 'base64 endcrypted key to connect to Google'
required: true
APP_ID:
description: 'GH App ID to request token for homebrew update'
required: true
APP_PRIVATE_KEY:
description: 'GH App Private Key to request token for homebrew update'
required: true
jobs:
version:
@ -27,6 +33,33 @@ jobs:
semantic_version: ${{ inputs.semantic_version }}
dry_run: false
# TODO: remove the publish job and publish releases directly with the @semantic-release/github plugin (remove draftRelease: true)
# as soon as it supports configuring the create release payload property make_latest to "legacy"
# https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release--parameters
publish:
runs-on: ubuntu-22.04
needs: [ version ]
steps:
- id: get_release
uses: cardinalby/git-get-release-action@v1
with:
commitSha: ${{ github.sha }}
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Release
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: ${{ steps.get_release.outputs.id }},
draft: false,
make_latest: "legacy"
});
docker:
runs-on: ubuntu-22.04
needs: [ version ]
@ -73,3 +106,23 @@ jobs:
docker buildx imagetools create \
--tag ${{ inputs.image_name }}:latest-debug \
${{ inputs.build_image_name }}-debug
homebrew-tap:
runs-on: ubuntu-22.04
needs: version
if: ${{ github.ref_name == 'next' }}
continue-on-error: true
steps:
- name: generate token
uses: tibdex/github-app-token@v2
id: generate-token
with:
app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Trigger Homebrew
env:
VERSION: ${{ needs.version.outputs.version }}
RUN_ID: ${{ github.run_id }}
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
run: |
gh workflow -R zitadel/homebrew-tap run update.yml -f runId=${RUN_ID} -f version=${VERSION}

View File

@ -43,6 +43,7 @@ jobs:
semantic_version: ${{ inputs.semantic_version }}
extra_plugins: |
@semantic-release/exec@6.0.3
@semantic-release/github@10.0.2
-
name: output
id: output

5
.gitignore vendored
View File

@ -82,3 +82,8 @@ go.work
go.work.sum
# Local Netlify folder
.netlify
load-test/node_modules
load-test/yarn-error.log
load-test/dist
.vercel

View File

@ -8,7 +8,7 @@ issues:
run:
concurrency: 4
timeout: 10m
go: '1.21'
go: '1.22'
skip-dirs:
- .artifacts
- .backups

View File

@ -9,6 +9,7 @@ module.exports = {
[
"@semantic-release/github",
{
draftRelease: true,
assets: [
{
path: ".artifacts/zitadel-linux-amd64/zitadel-linux-amd64.tar.gz",

View File

@ -108,13 +108,13 @@ Please make sure you cover your changes with tests before marking a Pull Request
The code consists of the following parts:
| name | description | language | where to find |
| --------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------- |
| backend | Service that serves the grpc(-web) and RESTful API | [go](https://go.dev) | [API implementation](./internal/api/grpc) |
| console | Frontend the user interacts with after log in | [Angular](https://angular.io), [Typescript](https://www.typescriptlang.org) | [./console](./console) |
| name | description | language | where to find |
| --------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------- | -------------------------------------------------- |
| backend | Service that serves the grpc(-web) and RESTful API | [go](https://go.dev) | [API implementation](./internal/api/grpc) |
| console | Frontend the user interacts with after log in | [Angular](https://angular.io), [Typescript](https://www.typescriptlang.org) | [./console](./console) |
| login | Server side rendered frontend the user interacts with during login | [go](https://go.dev), [go templates](https://pkg.go.dev/html/template) | [./internal/api/ui/login](./internal/api/ui/login) |
| API definitions | Specifications of the API | [Protobuf](https://developers.google.com/protocol-buffers) | [./proto/zitadel](./proto/zitadel) |
| docs | Project documentation made with docusaurus | [Docusaurus](https://docusaurus.io/) | [./docs](./docs) |
| API definitions | Specifications of the API | [Protobuf](https://developers.google.com/protocol-buffers) | [./proto/zitadel](./proto/zitadel) |
| docs | Project documentation made with docusaurus | [Docusaurus](https://docusaurus.io/) | [./docs](./docs) |
Please validate and test the code before you contribute.
@ -129,18 +129,32 @@ We add the label "good first issue" for problems we think are a good starting po
We are committed to creating a welcoming and inclusive community for all developers, regardless of their gender identity or expression. To achieve this, we are actively working to ensure that our contribution guidelines are gender-neutral and use inclusive language.
**Use gender-neutral pronouns**:
**Use gender-neutral pronouns**:
Don't use gender-specific pronouns unless the person you're referring to is actually that gender.
In particular, don't use he, him, his, she, or her as gender-neutral pronouns, and don't use he/she or (s)he or other such punctuational approaches. Instead, use the singular they.
**Choose gender-neutral alternatives**:
Opt for gender-neutral terms instead of gendered ones whenever possible.
**Choose gender-neutral alternatives**:
Opt for gender-neutral terms instead of gendered ones whenever possible.
Replace "policeman" with "police officer," "manpower" with "workforce," and "businessman" with "entrepreneur" or "businessperson."
**Avoid ableist language**:
Ableist language includes words or phrases such as crazy, insane, blind to or blind eye to, cripple, dumb, and others.
Choose alternative words depending on the context.
### Developing ZITADEL with Dev Containers
Follow the instructions provided by your code editor/IDE to initiate the development container. This typically involves opening the "Command Palette" or similar functionality and searching for commands related to "Dev Containers" or "Remote Containers". The quick start guide for VS Code can found [here](https://code.visualstudio.com/docs/devcontainers/containers#_quick-start-open-an-existing-folder-in-a-container)
When you are connected to the container run the following commands to start ZITADEL.
```bash
make compile && ./zitadel start-from-init --masterkey MasterkeyNeedsToHave32Characters --tlsMode disabled
```
ZITADEL serves traffic as soon as you can see the following log line:
`INFO[0001] server is listening on [::]:8080`
### Backend/login
By executing the commands from this section, you run everything you need to develop the ZITADEL backend locally.
@ -154,7 +168,7 @@ ZITADEL uses [golangci-lint](https://golangci-lint.run) for code quality checks.
The commands in this section are tested against the following software versions:
- [Docker version 20.10.17](https://docs.docker.com/engine/install/)
- [Go version 1.21](https://go.dev/doc/install)
- [Go version 1.22](https://go.dev/doc/install)
- [Delve 1.9.1](https://github.com/go-delve/delve/tree/v1.9.1/Documentation/installation)
Make some changes to the source code, then run the database locally.
@ -194,7 +208,7 @@ make core_unit_test
To test the database-connected gRPC API, run PostgreSQL and CockroachDB, set up a ZITADEL instance and run the tests including integration tests:
```bash
export INTEGRATION_DB_FLAVOR="postgres" ZITADEL_MASTERKEY="MasterkeyNeedsToHave32Characters"
export INTEGRATION_DB_FLAVOR="cockroach" ZITADEL_MASTERKEY="MasterkeyNeedsToHave32Characters"
docker compose -f internal/integration/config/docker-compose.yaml up --pull always --wait ${INTEGRATION_DB_FLAVOR}
make core_integration_test
docker compose -f internal/integration/config/docker-compose.yaml down
@ -398,6 +412,13 @@ ZITADEL loads translations from four files:
You may edit the texts in these files or create a new file for additional language support. Make sure you set the locale (ISO 639-1 code) as the name of the new language file.
Please make sure that the languages within the files remain in their own language, e.g. German must always be `Deutsch.
If you have added support for a new language, please also ensure that it is added in the list of languages in all the other language files.
You also have to add some changes to the following files:
- [Register Local File](./console/src/app/app.module.ts)
- [Add Supported Language](./console/src/app/utils/language.ts)
- [Customized Text Docs](./docs/docs/guides/manage/customize/texts.md)
- [Add language option](./internal/api/ui/login/static/templates/external_not_found_option.html)
## Want to start ZITADEL?

63
MEETING_SCHEDULE.md Normal file
View File

@ -0,0 +1,63 @@
# ZITADEL Office Hours
Dear community!
We're excited to announce bi-weekly office hours.
## #2 New Resources and Settings APIs
**Shape the future of ZITADEL Let's redesign the API for a better developer experience!**
Dear community,
Following the great success of our first office hours, we're back for round two! This time, we're focusing on YOU and how we can build the best possible ZITADEL API together.
We've been working on some ideas for the API, and we're excited to share them with you during the session. But more importantly, we want to hear YOUR thoughts! What does your dream ZITADEL API look like? What improvements would make your development life easier?
Join the open discussion next Wednesday in the office hours voice channel on Discord. We're ready for your honest feedback and fresh perspectives that help us shape the future of ZITADEL!
**What to expect**:
* **Our Suggestions**: @eliobischof will walk you through the improvement suggestions.
* **Open Discussion**: Get your questions answered directly by the ZITADEL team, describe your pain points and drop your thoughts in an
open discussion.
**Details**:
* **Target Audience**: Developers and IT Ops personnel using ZITADEL
* **Topic**: API Redesign and Q&A
* **When**: Wednesday 12th of June 12 pm PST / 3 pm EST / 9 pm CEST
* **Duration**: about 1 hour
* **Platform**: ZITADEL Discord Server (Join us here: https://zitadel.com/office-hours?event=1248016231936692274 )
**In the meantime**:
KUDOS, if you already [have a look at our proposal](https://zitadel.com/docs/apis/v3) before the start of the event. Share any inputs in the chat of the [office hours channel](https://zitadel.com/office-hours) on our Discord server.
We look forward to seeing you there!
P.S. Spread the word! Share this announcement with your fellow ZITADEL users who might be interested 📢
## #1 Dive Deep into Actions v2
The first office hour is dedicated to exploring the [new Actions v2 feature](https://zitadel.com/docs/concepts/features/actions_v2).
What to expect:
* **Deep Dive**: @adlerhurst will walk you through the functionalities and benefits of Actions v2.
* **Live Q&A**: Get your questions answered directly by the ZITADEL team during the dedicated Q&A session.
Details:
* **Target Audience**: Developers and IT Ops personnel using ZITADEL
* **Topic**: Actions v2 deep dive and Q&A
* **When**: Wednesday 29th of May 12 pm PST / 3 pm EST / 9 pm CEST
* **Duration**: about 1 hour
* **Platform**: Zitadel Discord Server (Join us here: https://zitadel.com/office-hours?event=1243282884677341275 )
In the meantime:
Feel free to share any questions you already have about Actions v2 in the chat of the [office hours channel](https://zitadel.com/office-hours) on our Discord server.
We look forward to seeing you there!
P.S. Spread the word! Share this announcement with your fellow ZITADEL users who might be interested.

View File

@ -33,10 +33,10 @@ core_static:
.PHONY: core_generate_all
core_generate_all:
go install github.com/dmarkham/enumer@v1.5.9 # https://pkg.go.dev/github.com/dmarkham/enumer?tab=versions
go install github.com/dmarkham/enumer@v1.5.10 # https://pkg.go.dev/github.com/dmarkham/enumer?tab=versions
go install github.com/rakyll/statik@v0.1.7 # https://pkg.go.dev/github.com/rakyll/statik?tab=versions
go install go.uber.org/mock/mockgen@v0.4.0 # https://pkg.go.dev/go.uber.org/mock/mockgen?tab=versions
go install golang.org/x/tools/cmd/stringer@v0.17.0 # https://pkg.go.dev/golang.org/x/tools/cmd/stringer?tab=versions
go install golang.org/x/tools/cmd/stringer@v0.22.0 # https://pkg.go.dev/golang.org/x/tools/cmd/stringer?tab=versions
go generate ./...
.PHONY: core_assets
@ -57,12 +57,12 @@ endif
.PHONY: core_grpc_dependencies
core_grpc_dependencies:
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.32 # https://pkg.go.dev/google.golang.org/protobuf/cmd/protoc-gen-go?tab=versions
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3 # https://pkg.go.dev/google.golang.org/grpc/cmd/protoc-gen-go-grpc?tab=versions
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.19.0 # https://pkg.go.dev/github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway?tab=versions
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.19.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.0.3 # https://pkg.go.dev/github.com/envoyproxy/protoc-gen-validate?tab=versions
go install github.com/bufbuild/buf/cmd/buf@v1.28.1 # https://pkg.go.dev/github.com/bufbuild/buf/cmd/buf?tab=versions
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2 # https://pkg.go.dev/google.golang.org/protobuf/cmd/protoc-gen-go?tab=versions
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.4 # https://pkg.go.dev/google.golang.org/grpc/cmd/protoc-gen-go-grpc?tab=versions
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.20.0 # https://pkg.go.dev/github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway?tab=versions
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.20.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.0.4 # https://pkg.go.dev/github.com/envoyproxy/protoc-gen-validate?tab=versions
go install github.com/bufbuild/buf/cmd/buf@v1.34.0 # https://pkg.go.dev/github.com/bufbuild/buf/cmd/buf?tab=versions
.PHONY: core_api
core_api: core_api_generator core_grpc_dependencies
@ -115,6 +115,10 @@ core_integration_setup:
core_integration_test: core_integration_setup
go test -tags=integration -race -p 1 -coverprofile=profile.cov -coverpkg=./internal/...,./cmd/... ./...
.PHONY: core_integration_test_fast
core_integration_test_fast: core_integration_setup
go test -tags=integration -p 1 ./...
.PHONY: console_lint
console_lint:
cd console && \

View File

@ -30,6 +30,9 @@
<img src="./docs/static/logos/oidc-cert.png" /></a>
</p>
|Community Meeting|
|------------------|
|ZITADEL holds bi-weekly community calls. To join the community calls or to watch previous meeting notes and recordings, please visit the [meeting schedule](https://github.com/zitadel/zitadel/blob/main/MEETING_SCHEDULE.md).|
Are you searching for a user management tool that is quickly set up like Auth0 and open source like Keycloak?
@ -96,7 +99,7 @@ Yet it offers everything you need for a customer identity ([CIAM](https://zitade
- [API-first approach](https://zitadel.com/docs/apis/introduction)
- [Multi-tenancy](https://zitadel.com/docs/guides/solution-scenarios/b2b) authentication and access management
- Strong audit trail thanks to [event sourcing](https://zitadel.com/docs/concepts/eventstore/overview) as storage pattern
- [Strong audit trail](https://zitadel.com/docs/concepts/features/audit-trail) thanks to [event sourcing](https://zitadel.com/docs/concepts/eventstore/overview) as storage pattern
- [Actions](https://zitadel.com/docs/apis/actions/introduction) to react on events with custom code and extended ZITADEL for you needs
- [Branding](https://zitadel.com/docs/guides/manage/customize/branding) for a uniform user experience across multiple organizations
- [Self-service](https://zitadel.com/docs/concepts/features/selfservice) for end-users, business customers, and administrators
@ -107,16 +110,17 @@ Yet it offers everything you need for a customer identity ([CIAM](https://zitade
Authentication
- Single Sign On (SSO)
- Passkeys support (FIDO2 / WebAuthN)
- [Passkeys support (FIDO2 / WebAuthN)](https://zitadel.com/docs/concepts/features/passkeys)
- Username / Password
- Multifactor authentication with OTP, U2F, Email OTP, SMS OTP
- LDAP
- External enterprise identity providers and social logins
- [LDAP](https://zitadel.com/docs/guides/integrate/identity-providers/ldap)
- [External enterprise identity providers and social logins](https://zitadel.com/docs/guides/integrate/identity-providers/introduction)
- [Device authorization](https://zitadel.com/docs/guides/solution-scenarios/device-authorization)
- [OpenID Connect certified](https://openid.net/certification/#OPs) => [OIDC Endpoints](https://zitadel.com/docs/apis/openidoauth/endpoints)
- [SAML 2.0](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html) => [SAML Endpoints](https://zitadel.com/docs/apis/saml/endpoints)
- [Custom sessions](https://zitadel.com/docs/guides/integrate/login-ui/username-password) if you need to go beyond OIDC or SAML
- [Machine-to-machine](https://zitadel.com/docs/guides/integrate/serviceusers) with JWT profile, Personal Access Tokens (PAT), and Client Credentials
- [Machine-to-machine](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users) with JWT profile, Personal Access Tokens (PAT), and Client Credentials
- [Token exchange and impersonation](https://zitadel.com/docs/guides/integrate/token-exchange)
Multi-Tenancy
@ -130,6 +134,10 @@ Integration
- [GRPC and REST APIs](https://zitadel.com/docs/apis/introduction) for every functionality and resource
- [Actions](https://zitadel.com/docs/apis/actions/introduction) to call any API, send webhooks, adjust workflows, or customize tokens
- [Role Based Access Control (RBAC)](https://zitadel.com/docs/guides/integrate/retrieve-user-roles)
- [Examples and SDKs](https://zitadel.com/docs/sdk-examples/introduction)
- [Audit Log and SOC/SIEM](https://zitadel.com/docs/guides/integrate/external-audit-log)
- [User registration and onboarding](https://zitadel.com/docs/guides/integrate/onboarding)
- [Hosted and custom login user interface](https://zitadel.com/docs/guides/integrate/login-ui)
Self-Service
- [Self-registration](https://zitadel.com/docs/concepts/features/selfservice#registration) including verification

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,8 @@ package initialise
import (
"errors"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v5/pgconn"
"github.com/zitadel/zitadel/internal/database"
)

View File

@ -19,6 +19,7 @@ var (
createUserStmt string
grantStmt string
settingsStmt string
databaseStmt string
createEventstoreStmt string
createProjectionsStmt string
@ -39,7 +40,7 @@ func New() *cobra.Command {
Long: `Sets up the minimum requirements to start ZITADEL.
Prerequisites:
- cockroachDB
- database (PostgreSql or cockroachdb)
The user provided by flags needs privileges to
- create the database if it does not exist
@ -53,7 +54,7 @@ The user provided by flags needs privileges to
},
}
cmd.AddCommand(newZitadel(), newDatabase(), newUser(), newGrant())
cmd.AddCommand(newZitadel(), newDatabase(), newUser(), newGrant(), newSettings())
return cmd
}
@ -62,6 +63,7 @@ func InitAll(ctx context.Context, config *Config) {
VerifyUser(config.Database.Username(), config.Database.Password()),
VerifyDatabase(config.Database.DatabaseName()),
VerifyGrant(config.Database.DatabaseName(), config.Database.Username()),
VerifySettings(config.Database.DatabaseName(), config.Database.Username()),
)
logging.OnError(err).Fatal("unable to initialize the database")
@ -147,6 +149,11 @@ func ReadStmts(typ string) (err error) {
return err
}
settingsStmt, err = readStmt(typ, "11_settings")
if err != nil {
return err
}
return nil
}

View File

@ -6,7 +6,9 @@ import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/zitadel/zitadel/internal/database"
db_mock "github.com/zitadel/zitadel/internal/database/mock"
)
type db struct {
@ -16,7 +18,7 @@ type db struct {
func prepareDB(t *testing.T, expectations ...expectation) db {
t.Helper()
client, mock, err := sqlmock.New()
client, mock, err := sqlmock.New(sqlmock.ValueConverterOption(new(db_mock.TypeConverter)))
if err != nil {
t.Fatalf("unable to create sql mock: %v", err)
}
@ -44,7 +46,7 @@ func expectExec(stmt string, err error, args ...driver.Value) expectation {
func expectQuery(stmt string, err error, columns []string, rows [][]driver.Value, args ...driver.Value) expectation {
return func(m sqlmock.Sqlmock) {
res := sqlmock.NewRows(columns)
res := m.NewRows(columns)
for _, row := range rows {
res.AddRow(row...)
}

View File

@ -0,0 +1,4 @@
-- replace the first %[1]q with the database in double quotes
-- replace the second \%[2]q with the user in double quotes$
-- For more information see technical advisory 10009 (https://zitadel.com/docs/support/advisory/a10009)
ALTER ROLE %[2]q IN DATABASE %[1]q SET enable_durable_locking_for_serializable = on;

View File

@ -38,6 +38,6 @@ func VerifyDatabase(databaseName string) func(*database.DB) error {
return func(db *database.DB) error {
logging.WithFields("database", databaseName).Info("verify database")
return exec(db, fmt.Sprintf(string(databaseStmt), databaseName), []string{dbAlreadyExistsCode})
return exec(db, fmt.Sprintf(databaseStmt, databaseName), []string{dbAlreadyExistsCode})
}
}

View File

@ -0,0 +1,44 @@
package initialise
import (
_ "embed"
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database"
)
func newSettings() *cobra.Command {
return &cobra.Command{
Use: "settings",
Short: "Ensures proper settings on the database",
Long: `Ensures proper settings on the database.
Prerequisites:
- cockroachDB or postgreSQL
Cockroach
- Sets enable_durable_locking_for_serializable to on for the zitadel user and database
`,
Run: func(cmd *cobra.Command, args []string) {
config := MustNewConfig(viper.GetViper())
err := initialise(config.Database, VerifySettings(config.Database.DatabaseName(), config.Database.Username()))
logging.OnError(err).Fatal("unable to set settings")
},
}
}
func VerifySettings(databaseName, username string) func(*database.DB) error {
return func(db *database.DB) error {
if db.Type() == "postgres" {
return nil
}
logging.WithFields("user", username, "database", databaseName).Info("verify settings")
return exec(db, fmt.Sprintf(settingsStmt, databaseName, username), nil)
}
}

View File

@ -95,7 +95,8 @@ func createEncryptionKeys(ctx context.Context, db *database.DB) error {
return err
}
if _, err = tx.Exec(createEncryptionKeysStmt); err != nil {
tx.Rollback()
rollbackErr := tx.Rollback()
logging.OnError(rollbackErr).Error("rollback failed")
return err
}
@ -110,7 +111,7 @@ func createEvents(ctx context.Context, db *database.DB) (err error) {
defer func() {
if err != nil {
rollbackErr := tx.Rollback()
logging.OnError(rollbackErr).Debug("rollback failed")
logging.OnError(rollbackErr).Error("rollback failed")
return
}
err = tx.Commit()

View File

@ -2,7 +2,6 @@ package key
import (
"errors"
"io/ioutil"
"os"
"github.com/spf13/cobra"
@ -42,7 +41,7 @@ func MasterKey(cmd *cobra.Command) (string, error) {
if masterKeyFromEnv {
return os.Getenv(envMasterKey), nil
}
data, err := ioutil.ReadFile(masterKeyFile)
data, err := os.ReadFile(masterKeyFile)
if err != nil {
return "", err
}

91
cmd/mirror/auth.go Normal file
View File

@ -0,0 +1,91 @@
package mirror
import (
"context"
_ "embed"
"io"
"time"
"github.com/jackc/pgx/v5/stdlib"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
)
func authCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "mirrors the auth requests table from one database to another",
Long: `mirrors the auth requests table from one database to another
ZITADEL needs to be initialized and set up with the --for-mirror flag
Only auth requests are mirrored`,
Run: func(cmd *cobra.Command, args []string) {
config := mustNewMigrationConfig(viper.GetViper())
copyAuth(cmd.Context(), config)
},
}
cmd.Flags().BoolVar(&shouldReplace, "replace", false, "allow delete auth requests of defined instances before copy")
return cmd
}
func copyAuth(ctx context.Context, config *Migration) {
sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery)
logging.OnError(err).Fatal("unable to connect to source database")
defer sourceClient.Close()
destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
logging.OnError(err).Fatal("unable to connect to destination database")
defer destClient.Close()
copyAuthRequests(ctx, sourceClient, destClient)
}
func copyAuthRequests(ctx context.Context, source, dest *database.DB) {
start := time.Now()
sourceConn, err := source.Conn(ctx)
logging.OnError(err).Fatal("unable to acquire connection")
defer sourceConn.Close()
r, w := io.Pipe()
errs := make(chan error, 1)
go func() {
err = sourceConn.Raw(func(driverConn interface{}) error {
conn := driverConn.(*stdlib.Conn).Conn()
_, err := conn.PgConn().CopyTo(ctx, w, "COPY (SELECT id, regexp_replace(request::TEXT, '\\\\u0000', '', 'g')::JSON request, code, request_type, creation_date, change_date, instance_id FROM auth.auth_requests "+instanceClause()+") TO STDOUT")
w.Close()
return err
})
errs <- err
}()
destConn, err := dest.Conn(ctx)
logging.OnError(err).Fatal("unable to acquire connection")
defer destConn.Close()
var affected int64
err = destConn.Raw(func(driverConn interface{}) error {
conn := driverConn.(*stdlib.Conn).Conn()
if shouldReplace {
_, err := conn.Exec(ctx, "DELETE FROM auth.auth_requests "+instanceClause())
if err != nil {
return err
}
}
tag, err := conn.PgConn().CopyFrom(ctx, r, "COPY auth.auth_requests FROM STDIN")
affected = tag.RowsAffected()
return err
})
logging.OnError(err).Fatal("unable to copy auth requests to destination")
logging.OnError(<-errs).Fatal("unable to copy auth requests from source")
logging.WithFields("took", time.Since(start), "count", affected).Info("auth requests migrated")
}

80
cmd/mirror/config.go Normal file
View File

@ -0,0 +1,80 @@
package mirror
import (
_ "embed"
"time"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/cmd/hooks"
"github.com/zitadel/zitadel/internal/actions"
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/config/hook"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/id"
)
type Migration struct {
Source database.Config
Destination database.Config
EventBulkSize uint32
Log *logging.Config
Machine *id.Config
}
var (
//go:embed defaults.yaml
defaultConfig []byte
)
func mustNewMigrationConfig(v *viper.Viper) *Migration {
config := new(Migration)
mustNewConfig(v, config)
err := config.Log.SetLogger()
logging.OnError(err).Fatal("unable to set logger")
id.Configure(config.Machine)
return config
}
func mustNewProjectionsConfig(v *viper.Viper) *ProjectionsConfig {
config := new(ProjectionsConfig)
mustNewConfig(v, config)
err := config.Log.SetLogger()
logging.OnError(err).Fatal("unable to set logger")
id.Configure(config.Machine)
return config
}
func mustNewConfig(v *viper.Viper, config any) {
err := v.Unmarshal(config,
viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
hooks.SliceTypeStringDecode[*domain.CustomMessageText],
hooks.SliceTypeStringDecode[*command.SetQuota],
hooks.SliceTypeStringDecode[internal_authz.RoleMapping],
hooks.MapTypeStringDecode[string, *internal_authz.SystemAPIUser],
hooks.MapTypeStringDecode[domain.Feature, any],
hooks.MapHTTPHeaderStringDecode,
hook.Base64ToBytesHookFunc(),
hook.TagToLanguageHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
database.DecodeHook,
actions.HTTPConfigDecodeHook,
hook.EnumHookFunc(internal_authz.MemberTypeString),
)),
)
logging.OnError(err).Fatal("unable to read default config")
}

114
cmd/mirror/defaults.yaml Normal file
View File

@ -0,0 +1,114 @@
Source:
cockroach:
Host: localhost # ZITADEL_DATABASE_COCKROACH_HOST
Port: 26257 # ZITADEL_DATABASE_COCKROACH_PORT
Database: zitadel # ZITADEL_DATABASE_COCKROACH_DATABASE
MaxOpenConns: 6 # ZITADEL_DATABASE_COCKROACH_MAXOPENCONNS
MaxIdleConns: 6 # ZITADEL_DATABASE_COCKROACH_MAXIDLECONNS
EventPushConnRatio: 0.33 # ZITADEL_DATABASE_COCKROACH_EVENTPUSHCONNRATIO
ProjectionSpoolerConnRatio: 0.33 # ZITADEL_DATABASE_COCKROACH_PROJECTIONSPOOLERCONNRATIO
MaxConnLifetime: 30m # ZITADEL_DATABASE_COCKROACH_MAXCONNLIFETIME
MaxConnIdleTime: 5m # ZITADEL_DATABASE_COCKROACH_MAXCONNIDLETIME
Options: "" # ZITADEL_DATABASE_COCKROACH_OPTIONS
User:
Username: zitadel # ZITADEL_DATABASE_COCKROACH_USER_USERNAME
Password: "" # ZITADEL_DATABASE_COCKROACH_USER_PASSWORD
SSL:
Mode: disable # ZITADEL_DATABASE_COCKROACH_USER_SSL_MODE
RootCert: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_ROOTCERT
Cert: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_CERT
Key: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_KEY
# Postgres is used as soon as a value is set
# The values describe the possible fields to set values
postgres:
Host: # ZITADEL_DATABASE_POSTGRES_HOST
Port: # ZITADEL_DATABASE_POSTGRES_PORT
Database: # ZITADEL_DATABASE_POSTGRES_DATABASE
MaxOpenConns: # ZITADEL_DATABASE_POSTGRES_MAXOPENCONNS
MaxIdleConns: # ZITADEL_DATABASE_POSTGRES_MAXIDLECONNS
MaxConnLifetime: # ZITADEL_DATABASE_POSTGRES_MAXCONNLIFETIME
MaxConnIdleTime: # ZITADEL_DATABASE_POSTGRES_MAXCONNIDLETIME
Options: # ZITADEL_DATABASE_POSTGRES_OPTIONS
User:
Username: # ZITADEL_DATABASE_POSTGRES_USER_USERNAME
Password: # ZITADEL_DATABASE_POSTGRES_USER_PASSWORD
SSL:
Mode: # ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE
RootCert: # ZITADEL_DATABASE_POSTGRES_USER_SSL_ROOTCERT
Cert: # ZITADEL_DATABASE_POSTGRES_USER_SSL_CERT
Key: # ZITADEL_DATABASE_POSTGRES_USER_SSL_KEY
Destination:
cockroach:
Host: localhost # ZITADEL_DATABASE_COCKROACH_HOST
Port: 26257 # ZITADEL_DATABASE_COCKROACH_PORT
Database: zitadel # ZITADEL_DATABASE_COCKROACH_DATABASE
MaxOpenConns: 0 # ZITADEL_DATABASE_COCKROACH_MAXOPENCONNS
MaxIdleConns: 0 # ZITADEL_DATABASE_COCKROACH_MAXIDLECONNS
MaxConnLifetime: 30m # ZITADEL_DATABASE_COCKROACH_MAXCONNLIFETIME
MaxConnIdleTime: 5m # ZITADEL_DATABASE_COCKROACH_MAXCONNIDLETIME
EventPushConnRatio: 0.01 # ZITADEL_DATABASE_COCKROACH_EVENTPUSHCONNRATIO
ProjectionSpoolerConnRatio: 0.5 # ZITADEL_DATABASE_COCKROACH_PROJECTIONSPOOLERCONNRATIO
Options: "" # ZITADEL_DATABASE_COCKROACH_OPTIONS
User:
Username: zitadel # ZITADEL_DATABASE_COCKROACH_USER_USERNAME
Password: "" # ZITADEL_DATABASE_COCKROACH_USER_PASSWORD
SSL:
Mode: disable # ZITADEL_DATABASE_COCKROACH_USER_SSL_MODE
RootCert: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_ROOTCERT
Cert: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_CERT
Key: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_KEY
# Postgres is used as soon as a value is set
# The values describe the possible fields to set values
postgres:
Host: # ZITADEL_DATABASE_POSTGRES_HOST
Port: # ZITADEL_DATABASE_POSTGRES_PORT
Database: # ZITADEL_DATABASE_POSTGRES_DATABASE
MaxOpenConns: # ZITADEL_DATABASE_POSTGRES_MAXOPENCONNS
MaxIdleConns: # ZITADEL_DATABASE_POSTGRES_MAXIDLECONNS
MaxConnLifetime: # ZITADEL_DATABASE_POSTGRES_MAXCONNLIFETIME
MaxConnIdleTime: # ZITADEL_DATABASE_POSTGRES_MAXCONNIDLETIME
Options: # ZITADEL_DATABASE_POSTGRES_OPTIONS
User:
Username: # ZITADEL_DATABASE_POSTGRES_USER_USERNAME
Password: # ZITADEL_DATABASE_POSTGRES_USER_PASSWORD
SSL:
Mode: # ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE
RootCert: # ZITADEL_DATABASE_POSTGRES_USER_SSL_ROOTCERT
Cert: # ZITADEL_DATABASE_POSTGRES_USER_SSL_CERT
Key: # ZITADEL_DATABASE_POSTGRES_USER_SSL_KEY
EventBulkSize: 10000
Projections:
# The maximum duration a transaction remains open
# before it spots left folding additional events
# and updates the table.
TransactionDuration: 0s # ZITADEL_PROJECTIONS_TRANSACTIONDURATION
# turn off scheduler during operation
RequeueEvery: 0s
ConcurrentInstances: 7
EventBulkLimit: 1000
Customizations:
notifications:
MaxFailureCount: 1
Eventstore:
MaxRetries: 3
Auth:
Spooler:
TransactionDuration: 0s #ZITADEL_AUTH_SPOOLER_TRANSACTIONDURATION
BulkLimit: 1000 #ZITADEL_AUTH_SPOOLER_BULKLIMIT
Admin:
Spooler:
TransactionDuration: 0s #ZITADEL_AUTH_SPOOLER_TRANSACTIONDURATION
BulkLimit: 10 #ZITADEL_AUTH_SPOOLER_BULKLIMIT
FirstInstance:
# We only need to create an empty zitadel database so this step must be skipped
Skip: true
Log:
Level: info

96
cmd/mirror/event.go Normal file
View File

@ -0,0 +1,96 @@
package mirror
import (
"context"
"github.com/zitadel/zitadel/internal/v2/eventstore"
"github.com/zitadel/zitadel/internal/v2/projection"
"github.com/zitadel/zitadel/internal/v2/readmodel"
"github.com/zitadel/zitadel/internal/v2/system"
mirror_event "github.com/zitadel/zitadel/internal/v2/system/mirror"
)
func queryLastSuccessfulMigration(ctx context.Context, destinationES *eventstore.EventStore, source string) (*readmodel.LastSuccessfulMirror, error) {
lastSuccess := readmodel.NewLastSuccessfulMirror(source)
if shouldIgnorePrevious {
return lastSuccess, nil
}
_, err := destinationES.Query(
ctx,
eventstore.NewQuery(
system.AggregateInstance,
lastSuccess,
eventstore.SetFilters(lastSuccess.Filter()),
),
)
if err != nil {
return nil, err
}
return lastSuccess, nil
}
func writeMigrationStart(ctx context.Context, sourceES *eventstore.EventStore, id string, destination string) (_ float64, err error) {
var cmd *eventstore.Command
if len(instanceIDs) > 0 {
cmd, err = mirror_event.NewStartedInstancesCommand(destination, instanceIDs)
if err != nil {
return 0, err
}
} else {
cmd = mirror_event.NewStartedSystemCommand(destination)
}
var position projection.HighestPosition
err = sourceES.Push(
ctx,
eventstore.NewPushIntent(
system.AggregateInstance,
eventstore.AppendAggregate(
system.AggregateOwner,
system.AggregateType,
id,
eventstore.CurrentSequenceMatches(0),
eventstore.AppendCommands(cmd),
),
eventstore.PushReducer(&position),
),
)
if err != nil {
return 0, err
}
return position.Position, nil
}
func writeMigrationSucceeded(ctx context.Context, destinationES *eventstore.EventStore, id, source string, position float64) error {
return destinationES.Push(
ctx,
eventstore.NewPushIntent(
system.AggregateInstance,
eventstore.AppendAggregate(
system.AggregateOwner,
system.AggregateType,
id,
eventstore.CurrentSequenceMatches(0),
eventstore.AppendCommands(mirror_event.NewSucceededCommand(source, position)),
),
),
)
}
func writeMigrationFailed(ctx context.Context, destinationES *eventstore.EventStore, id, source string, err error) error {
return destinationES.Push(
ctx,
eventstore.NewPushIntent(
system.AggregateInstance,
eventstore.AppendAggregate(
system.AggregateOwner,
system.AggregateType,
id,
eventstore.CurrentSequenceMatches(0),
eventstore.AppendCommands(mirror_event.NewFailedCommand(source, err)),
),
),
)
}

250
cmd/mirror/event_store.go Normal file
View File

@ -0,0 +1,250 @@
package mirror
import (
"context"
"database/sql"
_ "embed"
"errors"
"io"
"time"
"github.com/jackc/pgx/v5/stdlib"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/zitadel/logging"
db "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/v2/database"
"github.com/zitadel/zitadel/internal/v2/eventstore"
"github.com/zitadel/zitadel/internal/v2/eventstore/postgres"
"github.com/zitadel/zitadel/internal/zerrors"
)
var shouldIgnorePrevious bool
func eventstoreCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "eventstore",
Short: "mirrors the eventstore of an instance from one database to another",
Long: `mirrors the eventstore of an instance from one database to another
ZITADEL needs to be initialized and set up with the --for-mirror flag
Migrate only copies events2 and unique constraints`,
Run: func(cmd *cobra.Command, args []string) {
config := mustNewMigrationConfig(viper.GetViper())
copyEventstore(cmd.Context(), config)
},
}
cmd.Flags().BoolVar(&shouldReplace, "replace", false, "allow delete unique constraints of defined instances before copy")
cmd.Flags().BoolVar(&shouldIgnorePrevious, "ignore-previous", false, "ignores previous migrations of the events table")
return cmd
}
func copyEventstore(ctx context.Context, config *Migration) {
sourceClient, err := db.Connect(config.Source, false, dialect.DBPurposeQuery)
logging.OnError(err).Fatal("unable to connect to source database")
defer sourceClient.Close()
destClient, err := db.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
logging.OnError(err).Fatal("unable to connect to destination database")
defer destClient.Close()
copyEvents(ctx, sourceClient, destClient, config.EventBulkSize)
copyUniqueConstraints(ctx, sourceClient, destClient)
}
func positionQuery(db *db.DB) string {
switch db.Type() {
case "postgres":
return "SELECT EXTRACT(EPOCH FROM clock_timestamp())"
case "cockroach":
return "SELECT cluster_logical_timestamp()"
default:
logging.WithFields("db_type", db.Type()).Fatal("database type not recognized")
return ""
}
}
func copyEvents(ctx context.Context, source, dest *db.DB, bulkSize uint32) {
start := time.Now()
reader, writer := io.Pipe()
migrationID, err := id.SonyFlakeGenerator().Next()
logging.OnError(err).Fatal("unable to generate migration id")
sourceConn, err := source.Conn(ctx)
logging.OnError(err).Fatal("unable to acquire source connection")
destConn, err := dest.Conn(ctx)
logging.OnError(err).Fatal("unable to acquire dest connection")
sourceES := eventstore.NewEventstoreFromOne(postgres.New(source, &postgres.Config{
MaxRetries: 3,
}))
destinationES := eventstore.NewEventstoreFromOne(postgres.New(dest, &postgres.Config{
MaxRetries: 3,
}))
previousMigration, err := queryLastSuccessfulMigration(ctx, destinationES, source.DatabaseName())
logging.OnError(err).Fatal("unable to query latest successful migration")
maxPosition, err := writeMigrationStart(ctx, sourceES, migrationID, dest.DatabaseName())
logging.OnError(err).Fatal("unable to write migration started event")
logging.WithFields("from", previousMigration.Position, "to", maxPosition).Info("start event migration")
nextPos := make(chan bool, 1)
pos := make(chan float64, 1)
errs := make(chan error, 3)
go func() {
err := sourceConn.Raw(func(driverConn interface{}) error {
conn := driverConn.(*stdlib.Conn).Conn()
nextPos <- true
var i uint32
for position := range pos {
var stmt database.Statement
stmt.WriteString("COPY (SELECT instance_id, aggregate_type, aggregate_id, event_type, sequence, revision, created_at, regexp_replace(payload::TEXT, '\\\\u0000', '', 'g')::JSON payload, creator, owner, ")
stmt.WriteArg(position)
stmt.WriteString(" position, row_number() OVER (PARTITION BY instance_id ORDER BY position, in_tx_order) AS in_tx_order FROM eventstore.events2 ")
stmt.WriteString(instanceClause())
stmt.WriteString(" AND ")
database.NewNumberAtMost(maxPosition).Write(&stmt, "position")
stmt.WriteString(" AND ")
database.NewNumberGreater(previousMigration.Position).Write(&stmt, "position")
stmt.WriteString(" ORDER BY instance_id, position, in_tx_order")
stmt.WriteString(" LIMIT ")
stmt.WriteArg(bulkSize)
stmt.WriteString(" OFFSET ")
stmt.WriteArg(bulkSize * i)
stmt.WriteString(") TO STDOUT")
// Copy does not allow args so we use we replace the args in the statement
tag, err := conn.PgConn().CopyTo(ctx, writer, stmt.Debug())
if err != nil {
return zerrors.ThrowUnknownf(err, "MIGRA-KTuSq", "unable to copy events from source during iteration %d", i)
}
if tag.RowsAffected() < int64(bulkSize) {
return nil
}
nextPos <- true
i++
}
return nil
})
writer.Close()
close(nextPos)
errs <- err
}()
// generate next position for
go func() {
defer close(pos)
for range nextPos {
var position float64
err := dest.QueryRowContext(
ctx,
func(row *sql.Row) error {
return row.Scan(&position)
},
positionQuery(dest),
)
if err != nil {
errs <- zerrors.ThrowUnknown(err, "MIGRA-kMyPH", "unable to query next position")
return
}
pos <- position
}
}()
var eventCount int64
errs <- destConn.Raw(func(driverConn interface{}) error {
conn := driverConn.(*stdlib.Conn).Conn()
tag, err := conn.PgConn().CopyFrom(ctx, reader, "COPY eventstore.events2 FROM STDIN")
eventCount = tag.RowsAffected()
if err != nil {
return zerrors.ThrowUnknown(err, "MIGRA-DTHi7", "unable to copy events into destination")
}
return nil
})
close(errs)
writeCopyEventsDone(ctx, destinationES, migrationID, source.DatabaseName(), maxPosition, errs)
logging.WithFields("took", time.Since(start), "count", eventCount).Info("events migrated")
}
func writeCopyEventsDone(ctx context.Context, es *eventstore.EventStore, id, source string, position float64, errs <-chan error) {
joinedErrs := make([]error, 0, len(errs))
for err := range errs {
joinedErrs = append(joinedErrs, err)
}
err := errors.Join(joinedErrs...)
if err != nil {
logging.WithError(err).Error("unable to mirror events")
err := writeMigrationFailed(ctx, es, id, source, err)
logging.OnError(err).Fatal("unable to write failed event")
return
}
err = writeMigrationSucceeded(ctx, es, id, source, position)
logging.OnError(err).Fatal("unable to write failed event")
}
func copyUniqueConstraints(ctx context.Context, source, dest *db.DB) {
start := time.Now()
reader, writer := io.Pipe()
errs := make(chan error, 1)
sourceConn, err := source.Conn(ctx)
logging.OnError(err).Fatal("unable to acquire source connection")
go func() {
err := sourceConn.Raw(func(driverConn interface{}) error {
conn := driverConn.(*stdlib.Conn).Conn()
var stmt database.Statement
stmt.WriteString("COPY (SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints ")
stmt.WriteString(instanceClause())
stmt.WriteString(") TO stdout")
_, err := conn.PgConn().CopyTo(ctx, writer, stmt.String())
writer.Close()
return err
})
errs <- err
}()
destConn, err := dest.Conn(ctx)
logging.OnError(err).Fatal("unable to acquire dest connection")
var eventCount int64
err = destConn.Raw(func(driverConn interface{}) error {
conn := driverConn.(*stdlib.Conn).Conn()
if shouldReplace {
var stmt database.Statement
stmt.WriteString("DELETE FROM eventstore.unique_constraints ")
stmt.WriteString(instanceClause())
_, err := conn.Exec(ctx, stmt.String())
if err != nil {
return err
}
}
tag, err := conn.PgConn().CopyFrom(ctx, reader, "COPY eventstore.unique_constraints FROM stdin")
eventCount = tag.RowsAffected()
return err
})
logging.OnError(err).Fatal("unable to copy unique constraints to destination")
logging.OnError(<-errs).Fatal("unable to copy unique constraints from source")
logging.WithFields("took", time.Since(start), "count", eventCount).Info("unique constraints migrated")
}

100
cmd/mirror/mirror.go Normal file
View File

@ -0,0 +1,100 @@
package mirror
import (
"bytes"
_ "embed"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/cmd/key"
)
var (
instanceIDs []string
isSystem bool
shouldReplace bool
)
func New(configFiles *[]string) *cobra.Command {
cmd := &cobra.Command{
Use: "mirror",
Short: "mirrors all data of ZITADEL from one database to another",
Long: `mirrors all data of ZITADEL from one database to another
ZITADEL needs to be initialized and set up with --for-mirror
The command does mirror all data needed and recomputes the projections.
For more details call the help functions of the sub commands.
Order of execution:
1. mirror system tables
2. mirror auth tables
3. mirror event store tables
4. recompute projections
5. verify`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
err := viper.MergeConfig(bytes.NewBuffer(defaultConfig))
logging.OnError(err).Fatal("unable to read default config")
for _, file := range *configFiles {
viper.SetConfigFile(file)
err := viper.MergeInConfig()
logging.WithFields("file", file).OnError(err).Warn("unable to read config file")
}
},
Run: func(cmd *cobra.Command, args []string) {
config := mustNewMigrationConfig(viper.GetViper())
projectionConfig := mustNewProjectionsConfig(viper.GetViper())
masterKey, err := key.MasterKey(cmd)
logging.OnError(err).Fatal("unable to read master key")
copySystem(cmd.Context(), config)
copyAuth(cmd.Context(), config)
copyEventstore(cmd.Context(), config)
projections(cmd.Context(), projectionConfig, masterKey)
verifyMigration(cmd.Context(), config)
},
}
mirrorFlags(cmd)
cmd.Flags().BoolVar(&shouldIgnorePrevious, "ignore-previous", false, "ignores previous migrations of the events table")
cmd.Flags().BoolVar(&shouldReplace, "replace", false, `replaces all data of the following tables for the provided instances or all if the "--system"-flag is set:
* system.assets
* auth.auth_requests
* eventstore.unique_constraints
The flag should be provided if you want to execute the mirror command multiple times so that the static data are also mirrored to prevent inconsistent states.`)
migrateProjectionsFlags(cmd)
cmd.AddCommand(
eventstoreCmd(),
systemCmd(),
projectionsCmd(),
authCmd(),
verifyCmd(),
)
return cmd
}
func mirrorFlags(cmd *cobra.Command) {
cmd.PersistentFlags().StringSliceVar(&instanceIDs, "instance", nil, "id or comma separated ids of the instance(s) to migrate. Either this or the `--system`-flag must be set. Make sure to always use the same flag if you execute the command multiple times.")
cmd.PersistentFlags().BoolVar(&isSystem, "system", false, "migrates the whole system. Either this or the `--instance`-flag must be set. Make sure to always use the same flag if you execute the command multiple times.")
cmd.MarkFlagsOneRequired("system", "instance")
cmd.MarkFlagsMutuallyExclusive("system", "instance")
}
func instanceClause() string {
if isSystem {
return "WHERE instance_id <> ''"
}
for i := range instanceIDs {
instanceIDs[i] = "'" + instanceIDs[i] + "'"
}
// COPY does not allow parameters so we need to set them directly
return "WHERE instance_id IN (" + strings.Join(instanceIDs, ", ") + ")"
}

316
cmd/mirror/projections.go Normal file
View File

@ -0,0 +1,316 @@
package mirror
import (
"context"
"database/sql"
"net/http"
"sync"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/cmd/encryption"
"github.com/zitadel/zitadel/cmd/key"
"github.com/zitadel/zitadel/cmd/tls"
admin_es "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing"
admin_handler "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing/handler"
admin_view "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing/view"
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/api/ui/login"
auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing"
auth_handler "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler"
auth_view "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view"
"github.com/zitadel/zitadel/internal/authz"
authz_es "github.com/zitadel/zitadel/internal/authz/repository/eventsourcing/eventstore"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/config/systemdefaults"
crypto_db "github.com/zitadel/zitadel/internal/crypto/database"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql"
new_es "github.com/zitadel/zitadel/internal/eventstore/v3"
"github.com/zitadel/zitadel/internal/i18n"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/notification"
"github.com/zitadel/zitadel/internal/notification/handlers"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/query/projection"
static_config "github.com/zitadel/zitadel/internal/static/config"
es_v4 "github.com/zitadel/zitadel/internal/v2/eventstore"
es_v4_pg "github.com/zitadel/zitadel/internal/v2/eventstore/postgres"
"github.com/zitadel/zitadel/internal/webauthn"
)
func projectionsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "projections",
Short: "calls the projections synchronously",
Run: func(cmd *cobra.Command, args []string) {
config := mustNewProjectionsConfig(viper.GetViper())
masterKey, err := key.MasterKey(cmd)
logging.OnError(err).Fatal("unable to read master key")
projections(cmd.Context(), config, masterKey)
},
}
migrateProjectionsFlags(cmd)
return cmd
}
type ProjectionsConfig struct {
Destination database.Config
Projections projection.Config
EncryptionKeys *encryption.EncryptionKeyConfig
SystemAPIUsers map[string]*internal_authz.SystemAPIUser
Eventstore *eventstore.Config
Admin admin_es.Config
Auth auth_es.Config
Log *logging.Config
Machine *id.Config
ExternalPort uint16
ExternalDomain string
ExternalSecure bool
InternalAuthZ internal_authz.Config
SystemDefaults systemdefaults.SystemDefaults
Telemetry *handlers.TelemetryPusherConfig
Login login.Config
OIDC oidc.Config
WebAuthNName string
DefaultInstance command.InstanceSetup
AssetStorage static_config.AssetStorageConfig
}
func migrateProjectionsFlags(cmd *cobra.Command) {
key.AddMasterKeyFlag(cmd)
tls.AddTLSModeFlag(cmd)
}
func projections(
ctx context.Context,
config *ProjectionsConfig,
masterKey string,
) {
start := time.Now()
client, err := database.Connect(config.Destination, false, dialect.DBPurposeQuery)
logging.OnError(err).Fatal("unable to connect to database")
keyStorage, err := crypto_db.NewKeyStorage(client, masterKey)
logging.OnError(err).Fatal("cannot start key storage")
keys, err := encryption.EnsureEncryptionKeys(ctx, config.EncryptionKeys, keyStorage)
logging.OnError(err).Fatal("unable to read encryption keys")
staticStorage, err := config.AssetStorage.NewStorage(client.DB)
logging.OnError(err).Fatal("unable create static storage")
config.Eventstore.Querier = old_es.NewCRDB(client)
esPusherDBClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
logging.OnError(err).Fatal("unable to connect eventstore push client")
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient)
es := eventstore.NewEventstore(config.Eventstore)
esV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(client, &es_v4_pg.Config{
MaxRetries: config.Eventstore.MaxRetries,
}))
sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC)
queries, err := query.StartQueries(
ctx,
es,
esV4.Querier,
client,
client,
config.Projections,
config.SystemDefaults,
keys.IDPConfig,
keys.OTP,
keys.OIDC,
keys.SAML,
config.InternalAuthZ.RolePermissionMappings,
sessionTokenVerifier,
func(q *query.Queries) domain.PermissionCheck {
return func(ctx context.Context, permission, orgID, resourceID string) (err error) {
return internal_authz.CheckPermission(ctx, &authz_es.UserMembershipRepo{Queries: q}, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID)
}
},
0,
config.SystemAPIUsers,
false,
)
logging.OnError(err).Fatal("unable to start queries")
authZRepo, err := authz.Start(queries, es, client, keys.OIDC, config.ExternalSecure)
logging.OnError(err).Fatal("unable to start authz repo")
webAuthNConfig := &webauthn.Config{
DisplayName: config.WebAuthNName,
ExternalSecure: config.ExternalSecure,
}
commands, err := command.StartCommands(
es,
config.SystemDefaults,
config.InternalAuthZ.RolePermissionMappings,
staticStorage,
webAuthNConfig,
config.ExternalDomain,
config.ExternalSecure,
config.ExternalPort,
keys.IDPConfig,
keys.OTP,
keys.SMTP,
keys.SMS,
keys.User,
keys.DomainVerification,
keys.OIDC,
keys.SAML,
&http.Client{},
func(ctx context.Context, permission, orgID, resourceID string) (err error) {
return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID)
},
sessionTokenVerifier,
config.OIDC.DefaultAccessTokenLifetime,
config.OIDC.DefaultRefreshTokenExpiration,
config.OIDC.DefaultRefreshTokenIdleExpiration,
config.DefaultInstance.SecretGenerators,
)
logging.OnError(err).Fatal("unable to start commands")
err = projection.Create(ctx, client, es, config.Projections, keys.OIDC, keys.SAML, config.SystemAPIUsers)
logging.OnError(err).Fatal("unable to start projections")
i18n.MustLoadSupportedLanguagesFromDir()
notification.Register(
ctx,
config.Projections.Customizations["notifications"],
config.Projections.Customizations["notificationsquotas"],
config.Projections.Customizations["telemetry"],
*config.Telemetry,
config.ExternalDomain,
config.ExternalPort,
config.ExternalSecure,
commands,
queries,
es,
config.Login.DefaultOTPEmailURLV2,
config.SystemDefaults.Notifications.FileSystemPath,
keys.User,
keys.SMTP,
keys.SMS,
)
config.Auth.Spooler.Client = client
config.Auth.Spooler.Eventstore = es
authView, err := auth_view.StartView(config.Auth.Spooler.Client, keys.OIDC, queries, config.Auth.Spooler.Eventstore)
logging.OnError(err).Fatal("unable to start auth view")
auth_handler.Register(ctx, config.Auth.Spooler, authView, queries)
config.Admin.Spooler.Client = client
config.Admin.Spooler.Eventstore = es
adminView, err := admin_view.StartView(config.Admin.Spooler.Client)
logging.OnError(err).Fatal("unable to start admin view")
admin_handler.Register(ctx, config.Admin.Spooler, adminView, staticStorage)
instances := make(chan string, config.Projections.ConcurrentInstances)
failedInstances := make(chan string)
wg := sync.WaitGroup{}
wg.Add(int(config.Projections.ConcurrentInstances))
go func() {
for instance := range failedInstances {
logging.WithFields("instance", instance).Error("projection failed")
}
}()
for i := 0; i < int(config.Projections.ConcurrentInstances); i++ {
go execProjections(ctx, instances, failedInstances, &wg)
}
for _, instance := range queryInstanceIDs(ctx, client) {
instances <- instance
}
close(instances)
wg.Wait()
close(failedInstances)
logging.WithFields("took", time.Since(start)).Info("projections executed")
}
func execProjections(ctx context.Context, instances <-chan string, failedInstances chan<- string, wg *sync.WaitGroup) {
for instance := range instances {
logging.WithFields("instance", instance).Info("start projections")
ctx = internal_authz.WithInstanceID(ctx, instance)
err := projection.ProjectInstance(ctx)
if err != nil {
logging.WithFields("instance", instance).OnError(err).Info("trigger failed")
failedInstances <- instance
continue
}
err = admin_handler.ProjectInstance(ctx)
if err != nil {
logging.WithFields("instance", instance).OnError(err).Info("trigger admin handler failed")
failedInstances <- instance
continue
}
err = auth_handler.ProjectInstance(ctx)
if err != nil {
logging.WithFields("instance", instance).OnError(err).Info("trigger auth handler failed")
failedInstances <- instance
continue
}
err = notification.ProjectInstance(ctx)
if err != nil {
logging.WithFields("instance", instance).OnError(err).Info("trigger notification failed")
failedInstances <- instance
continue
}
logging.WithFields("instance", instance).Info("projections done")
}
wg.Done()
}
// returns the instance configured by flag
// or all instances which are not removed
func queryInstanceIDs(ctx context.Context, source *database.DB) []string {
if len(instanceIDs) > 0 {
return instanceIDs
}
instances := []string{}
err := source.QueryContext(
ctx,
func(r *sql.Rows) error {
for r.Next() {
var instance string
if err := r.Scan(&instance); err != nil {
return err
}
instances = append(instances, instance)
}
return r.Err()
},
"SELECT DISTINCT instance_id FROM eventstore.events2 WHERE instance_id <> '' AND aggregate_type = 'instance' AND event_type = 'instance.added' AND instance_id NOT IN (SELECT instance_id FROM eventstore.events2 WHERE instance_id <> '' AND aggregate_type = 'instance' AND event_type = 'instance.removed')",
)
logging.OnError(err).Fatal("unable to query instances")
return instances
}

139
cmd/mirror/system.go Normal file
View File

@ -0,0 +1,139 @@
package mirror
import (
"context"
_ "embed"
"io"
"time"
"github.com/jackc/pgx/v5/stdlib"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
)
func systemCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "system",
Short: "mirrors the system tables of ZITADEL from one database to another",
Long: `mirrors the system tables of ZITADEL from one database to another
ZITADEL needs to be initialized
Only keys and assets are mirrored`,
Run: func(cmd *cobra.Command, args []string) {
config := mustNewMigrationConfig(viper.GetViper())
copySystem(cmd.Context(), config)
},
}
cmd.Flags().BoolVar(&shouldReplace, "replace", false, "allow delete ALL keys and assets of defined instances before copy")
return cmd
}
func copySystem(ctx context.Context, config *Migration) {
sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery)
logging.OnError(err).Fatal("unable to connect to source database")
defer sourceClient.Close()
destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
logging.OnError(err).Fatal("unable to connect to destination database")
defer destClient.Close()
copyAssets(ctx, sourceClient, destClient)
copyEncryptionKeys(ctx, sourceClient, destClient)
}
func copyAssets(ctx context.Context, source, dest *database.DB) {
start := time.Now()
sourceConn, err := source.Conn(ctx)
logging.OnError(err).Fatal("unable to acquire source connection")
defer sourceConn.Close()
r, w := io.Pipe()
errs := make(chan error, 1)
go func() {
err = sourceConn.Raw(func(driverConn interface{}) error {
conn := driverConn.(*stdlib.Conn).Conn()
// ignore hash column because it's computed
_, err := conn.PgConn().CopyTo(ctx, w, "COPY (SELECT instance_id, asset_type, resource_owner, name, content_type, data, updated_at FROM system.assets "+instanceClause()+") TO stdout")
w.Close()
return err
})
errs <- err
}()
destConn, err := dest.Conn(ctx)
logging.OnError(err).Fatal("unable to acquire dest connection")
defer destConn.Close()
var eventCount int64
err = destConn.Raw(func(driverConn interface{}) error {
conn := driverConn.(*stdlib.Conn).Conn()
if shouldReplace {
_, err := conn.Exec(ctx, "DELETE FROM system.assets "+instanceClause())
if err != nil {
return err
}
}
tag, err := conn.PgConn().CopyFrom(ctx, r, "COPY system.assets (instance_id, asset_type, resource_owner, name, content_type, data, updated_at) FROM stdin")
eventCount = tag.RowsAffected()
return err
})
logging.OnError(err).Fatal("unable to copy assets to destination")
logging.OnError(<-errs).Fatal("unable to copy assets from source")
logging.WithFields("took", time.Since(start), "count", eventCount).Info("assets migrated")
}
func copyEncryptionKeys(ctx context.Context, source, dest *database.DB) {
start := time.Now()
sourceConn, err := source.Conn(ctx)
logging.OnError(err).Fatal("unable to acquire source connection")
defer sourceConn.Close()
r, w := io.Pipe()
errs := make(chan error, 1)
go func() {
err = sourceConn.Raw(func(driverConn interface{}) error {
conn := driverConn.(*stdlib.Conn).Conn()
// ignore hash column because it's computed
_, err := conn.PgConn().CopyTo(ctx, w, "COPY system.encryption_keys TO stdout")
w.Close()
return err
})
errs <- err
}()
destConn, err := dest.Conn(ctx)
logging.OnError(err).Fatal("unable to acquire dest connection")
defer destConn.Close()
var eventCount int64
err = destConn.Raw(func(driverConn interface{}) error {
conn := driverConn.(*stdlib.Conn).Conn()
if shouldReplace {
_, err := conn.Exec(ctx, "TRUNCATE system.encryption_keys")
if err != nil {
return err
}
}
tag, err := conn.PgConn().CopyFrom(ctx, r, "COPY system.encryption_keys FROM stdin")
eventCount = tag.RowsAffected()
return err
})
logging.OnError(err).Fatal("unable to copy encryption keys to destination")
logging.OnError(<-errs).Fatal("unable to copy encryption keys from source")
logging.WithFields("took", time.Since(start), "count", eventCount).Info("encryption keys migrated")
}

111
cmd/mirror/verify.go Normal file
View File

@ -0,0 +1,111 @@
package mirror
import (
"context"
"database/sql"
_ "embed"
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
)
func verifyCmd() *cobra.Command {
return &cobra.Command{
Use: "verify",
Short: "counts if source and dest have the same amount of entries",
Run: func(cmd *cobra.Command, args []string) {
config := mustNewMigrationConfig(viper.GetViper())
verifyMigration(cmd.Context(), config)
},
}
}
var schemas = []string{
"adminapi",
"auth",
"eventstore",
"projections",
"system",
}
func verifyMigration(ctx context.Context, config *Migration) {
sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery)
logging.OnError(err).Fatal("unable to connect to source database")
defer sourceClient.Close()
destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
logging.OnError(err).Fatal("unable to connect to destination database")
defer destClient.Close()
for _, schema := range schemas {
for _, table := range append(getTables(ctx, destClient, schema), getViews(ctx, destClient, schema)...) {
sourceCount := countEntries(ctx, sourceClient, table)
destCount := countEntries(ctx, destClient, table)
entry := logging.WithFields("table", table, "dest", destCount, "source", sourceCount)
if sourceCount == destCount {
entry.Debug("equal count")
continue
}
entry.WithField("diff", destCount-sourceCount).Info("unequal count")
}
}
}
func getTables(ctx context.Context, dest *database.DB, schema string) (tables []string) {
err := dest.QueryContext(
ctx,
func(r *sql.Rows) error {
for r.Next() {
var table string
if err := r.Scan(&table); err != nil {
return err
}
tables = append(tables, table)
}
return r.Err()
},
"SELECT CONCAT(schemaname, '.', tablename) FROM pg_tables WHERE schemaname = $1",
schema,
)
logging.WithFields("schema", schema).OnError(err).Fatal("unable to query tables")
return tables
}
func getViews(ctx context.Context, dest *database.DB, schema string) (tables []string) {
err := dest.QueryContext(
ctx,
func(r *sql.Rows) error {
for r.Next() {
var table string
if err := r.Scan(&table); err != nil {
return err
}
tables = append(tables, table)
}
return r.Err()
},
"SELECT CONCAT(schemaname, '.', viewname) FROM pg_views WHERE schemaname = $1",
schema,
)
logging.WithFields("schema", schema).OnError(err).Fatal("unable to query views")
return tables
}
func countEntries(ctx context.Context, client *database.DB, table string) (count int) {
err := client.QueryRowContext(
ctx,
func(r *sql.Row) error {
return r.Scan(&count)
},
fmt.Sprintf("SELECT COUNT(*) FROM %s %s", table, instanceClause()),
)
logging.WithFields("table", table, "db", client.DatabaseName()).OnError(err).Error("unable to count")
return count
}

View File

@ -26,6 +26,8 @@ type FirstInstance struct {
PatPath string
Features *command.InstanceFeatures
Skip bool
instanceSetup command.InstanceSetup
userEncryptionKey *crypto.KeyConfig
smtpEncryptionKey *crypto.KeyConfig
@ -42,6 +44,9 @@ type FirstInstance struct {
}
func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error {
if mig.Skip {
return nil
}
keyStorage, err := mig.verifyEncryptionKeys(ctx)
if err != nil {
return err

View File

@ -7,7 +7,7 @@ import (
"errors"
"strings"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v5/pgconn"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database"

27
cmd/setup/24.go Normal file
View File

@ -0,0 +1,27 @@
package setup
import (
"context"
_ "embed"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 24.sql
addTokenActor string
)
type AddActorToAuthTokens struct {
dbClient *database.DB
}
func (mig *AddActorToAuthTokens) Execute(ctx context.Context, _ eventstore.Event) error {
_, err := mig.dbClient.ExecContext(ctx, addTokenActor)
return err
}
func (mig *AddActorToAuthTokens) String() string {
return "24_add_actor_col_to_auth_tokens"
}

2
cmd/setup/24.sql Normal file
View File

@ -0,0 +1,2 @@
ALTER TABLE auth.tokens ADD COLUMN actor jsonb;
ALTER TABLE auth.refresh_tokens ADD COLUMN actor jsonb;

27
cmd/setup/25.go Normal file
View File

@ -0,0 +1,27 @@
package setup
import (
"context"
_ "embed"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 25.sql
addLowerFieldsToVerifiedEmail string
)
type User11AddLowerFieldsToVerifiedEmail struct {
dbClient *database.DB
}
func (mig *User11AddLowerFieldsToVerifiedEmail) Execute(ctx context.Context, _ eventstore.Event) error {
_, err := mig.dbClient.ExecContext(ctx, addLowerFieldsToVerifiedEmail)
return err
}
func (mig *User11AddLowerFieldsToVerifiedEmail) String() string {
return "25_user13_add_lower_fields_to_verified_email"
}

2
cmd/setup/25.sql Normal file
View File

@ -0,0 +1,2 @@
ALTER TABLE IF EXISTS projections.users13_notifications ADD COLUMN IF NOT EXISTS verified_email_lower TEXT GENERATED ALWAYS AS (lower(verified_email)) STORED;
CREATE INDEX IF NOT EXISTS users13_notifications_email_search ON projections.users13_notifications (instance_id, verified_email_lower);

27
cmd/setup/26.go Normal file
View File

@ -0,0 +1,27 @@
package setup
import (
"context"
_ "embed"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 26.sql
authUsers3 string
)
type AuthUsers3 struct {
dbClient *database.DB
}
func (mig *AuthUsers3) Execute(ctx context.Context, _ eventstore.Event) error {
_, err := mig.dbClient.ExecContext(ctx, authUsers3)
return err
}
func (mig *AuthUsers3) String() string {
return "26_auth_users3"
}

16
cmd/setup/26.sql Normal file
View File

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS auth.users3 (
instance_id TEXT NOT NULL,
id TEXT NOT NULL,
resource_owner TEXT NOT NULL,
change_date TIMESTAMPTZ NULL,
password_set BOOL NULL,
password_change TIMESTAMPTZ NULL,
last_login TIMESTAMPTZ NULL,
init_required BOOL NULL,
mfa_init_skipped TIMESTAMPTZ NULL,
username_change_required BOOL NULL,
passwordless_init_required BOOL NULL,
password_init_required BOOL NULL,
PRIMARY KEY (instance_id, id)
)

27
cmd/setup/27.go Normal file
View File

@ -0,0 +1,27 @@
package setup
import (
"context"
_ "embed"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 27.sql
addSAMLNameIDFormat string
)
type IDPTemplate6SAMLNameIDFormat struct {
dbClient *database.DB
}
func (mig *IDPTemplate6SAMLNameIDFormat) Execute(ctx context.Context, _ eventstore.Event) error {
_, err := mig.dbClient.ExecContext(ctx, addSAMLNameIDFormat)
return err
}
func (mig *IDPTemplate6SAMLNameIDFormat) String() string {
return "26_idp_templates6_add_saml_name_id_format"
}

2
cmd/setup/27.sql Normal file
View File

@ -0,0 +1,2 @@
ALTER TABLE IF EXISTS projections.idp_templates6_saml ADD COLUMN IF NOT EXISTS name_id_format SMALLINT;
ALTER TABLE IF EXISTS projections.idp_templates6_saml ADD COLUMN IF NOT EXISTS transient_mapping_attribute_name TEXT;

27
cmd/setup/28.go Normal file
View File

@ -0,0 +1,27 @@
package setup
import (
"context"
_ "embed"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 28.sql
addFieldTable string
)
type AddFieldTable struct {
dbClient *database.DB
}
func (mig *AddFieldTable) Execute(ctx context.Context, _ eventstore.Event) error {
_, err := mig.dbClient.ExecContext(ctx, addFieldTable)
return err
}
func (mig *AddFieldTable) String() string {
return "28_add_search_table"
}

64
cmd/setup/28.sql Normal file
View File

@ -0,0 +1,64 @@
CREATE TABLE eventstore.fields (
id TEXT NOT NULL DEFAULT gen_random_uuid()
, instance_id TEXT NOT NULL
, resource_owner TEXT NOT NULL
, aggregate_type TEXT NOT NULL
, aggregate_id TEXT NOT NULL
, object_type TEXT NOT NULL
, object_id TEXT NOT NULL
, object_revision INT2
, field_name TEXT NOT NULL
-- all the values of fields are inserted into value column as jsonb and if we need to index something we store it to the type specific column additionally
, "value" JSONB NOT NULL
, number_value NUMERIC GENERATED ALWAYS AS (CASE WHEN should_index AND JSONB_TYPEOF("value") = 'number' THEN "value"::NUMERIC ELSE NULL END) STORED
, text_value TEXT GENERATED ALWAYS AS (CASE WHEN should_index AND JSONB_TYPEOF("value") = 'string' THEN "value" #>> '{}' ELSE NULL END) STORED
, bool_value BOOLEAN GENERATED ALWAYS AS (CASE WHEN should_index AND JSONB_TYPEOF("value") = 'boolean' THEN "value"::BOOLEAN ELSE NULL END) STORED
-- if true the value must be unique within an instance
, value_must_be_unique BOOLEAN
-- if set to true the primitive value is indexed
, should_index BOOLEAN
, PRIMARY KEY (instance_id, id)
-- TODO: create issue to enable the foreign key as soon as the objects table is implemented
-- , CONSTRAINT f_objects_fk FOREIGN KEY (instance_id, resource_owner, object_type, object_id, object_revision) REFERENCES eventstore.objects (instance_id, resource_owner, object_type, object_id, object_revision) ON DELETE CASCADE
-- the constraint ensures that a primitive value is set if the value must be unique
, CONSTRAINT primitive_value_for_unique_check CHECK (
CASE
WHEN value_must_be_unique THEN num_nonnulls(number_value, text_value, bool_value) = 1
ELSE true
END
)
-- the constraint ensures that a primitive value is set if the value must be indexed
, CONSTRAINT primitive_value_for_index CHECK (
CASE
WHEN should_index THEN num_nonnulls(number_value, text_value, bool_value) = 1
ELSE true
END
)
);
-- unique constraints for primitive values
CREATE UNIQUE INDEX IF NOT EXISTS f_number_unique_idx ON eventstore.fields (instance_id, field_name, number_value) WHERE value_must_be_unique;
CREATE UNIQUE INDEX IF NOT EXISTS f_text_unique_idx ON eventstore.fields (instance_id, field_name, text_value) WHERE value_must_be_unique;
CREATE UNIQUE INDEX IF NOT EXISTS f_bool_unique_idx ON eventstore.fields (instance_id, field_name, bool_value) WHERE value_must_be_unique;
-- search index for primitive values
CREATE INDEX IF NOT EXISTS f_number_value_idx ON eventstore.fields (instance_id, object_type, field_name, number_value)
INCLUDE (resource_owner, object_id, object_revision, "value")
WHERE number_value IS NOT NULL ;
CREATE INDEX IF NOT EXISTS f_text_value_idx ON eventstore.fields (instance_id, object_type, field_name, text_value)
INCLUDE (resource_owner, object_id, object_revision, "value")
WHERE text_value IS NOT NULL ;
CREATE INDEX IF NOT EXISTS f_bool_value_idx ON eventstore.fields (instance_id, object_type, field_name, bool_value)
INCLUDE (resource_owner, object_id, object_revision, "value")
WHERE bool_value IS NOT NULL ;
-- search index for object by id
CREATE INDEX IF NOT EXISTS f_object_idx ON eventstore.fields (instance_id, object_type, object_id, object_revision)
INCLUDE (resource_owner, field_name, "value") ;

42
cmd/setup/29.go Normal file
View File

@ -0,0 +1,42 @@
package setup
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/repository/instance"
)
type FillFieldsForProjectGrant struct {
eventstore *eventstore.Eventstore
}
func (mig *FillFieldsForProjectGrant) Execute(ctx context.Context, _ eventstore.Event) error {
instances, err := mig.eventstore.InstanceIDs(
ctx,
0,
true,
eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs).
OrderDesc().
AddQuery().
AggregateTypes("instance").
EventTypes(instance.InstanceAddedEventType).
Builder(),
)
if err != nil {
return err
}
for _, instance := range instances {
ctx := authz.WithInstanceID(ctx, instance)
if err := projection.ProjectGrantFields.Trigger(ctx); err != nil {
return err
}
}
return nil
}
func (mig *FillFieldsForProjectGrant) String() string {
return "29_init_fields_for_project_grant"
}

42
cmd/setup/30.go Normal file
View File

@ -0,0 +1,42 @@
package setup
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/repository/instance"
)
type FillFieldsForOrgDomainVerified struct {
eventstore *eventstore.Eventstore
}
func (mig *FillFieldsForOrgDomainVerified) Execute(ctx context.Context, _ eventstore.Event) error {
instances, err := mig.eventstore.InstanceIDs(
ctx,
0,
true,
eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs).
OrderDesc().
AddQuery().
AggregateTypes("instance").
EventTypes(instance.InstanceAddedEventType).
Builder(),
)
if err != nil {
return err
}
for _, instance := range instances {
ctx := authz.WithInstanceID(ctx, instance)
if err := projection.OrgDomainVerifiedFields.Trigger(ctx); err != nil {
return err
}
}
return nil
}
func (mig *FillFieldsForOrgDomainVerified) String() string {
return "30_fill_fields_for_org_domain_verified"
}

27
cmd/setup/31.go Normal file
View File

@ -0,0 +1,27 @@
package setup
import (
"context"
_ "embed"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 31.sql
addAggregateIndexToFields string
)
type AddAggregateIndexToFields struct {
dbClient *database.DB
}
func (mig *AddAggregateIndexToFields) Execute(ctx context.Context, _ eventstore.Event) error {
_, err := mig.dbClient.ExecContext(ctx, addAggregateIndexToFields)
return err
}
func (mig *AddAggregateIndexToFields) String() string {
return "31_add_aggregate_index_to_fields"
}

1
cmd/setup/31.sql Normal file
View File

@ -0,0 +1 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS f_aggregate_object_type_idx ON eventstore.fields (aggregate_type, aggregate_id, object_type);

View File

@ -12,13 +12,14 @@ import (
"github.com/zitadel/zitadel/cmd/encryption"
"github.com/zitadel/zitadel/cmd/hooks"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/api/authz"
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/config/hook"
"github.com/zitadel/zitadel/internal/config/systemdefaults"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/notification/handlers"
@ -27,9 +28,10 @@ import (
)
type Config struct {
ForMirror bool
Database database.Config
SystemDefaults systemdefaults.SystemDefaults
InternalAuthZ authz.Config
InternalAuthZ internal_authz.Config
ExternalDomain string
ExternalPort uint16
ExternalSecure bool
@ -46,7 +48,7 @@ type Config struct {
Login login.Config
WebAuthNName string
Telemetry *handlers.TelemetryPusherConfig
SystemAPIUsers map[string]*authz.SystemAPIUser
SystemAPIUsers map[string]*internal_authz.SystemAPIUser
}
type InitProjections struct {
@ -60,15 +62,18 @@ func MustNewConfig(v *viper.Viper) *Config {
config := new(Config)
err := v.Unmarshal(config,
viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
hooks.SliceTypeStringDecode[*domain.CustomMessageText],
hooks.SliceTypeStringDecode[internal_authz.RoleMapping],
hooks.MapTypeStringDecode[string, *internal_authz.SystemAPIUser],
hooks.MapHTTPHeaderStringDecode,
database.DecodeHook,
actions.HTTPConfigDecodeHook,
hook.EnumHookFunc(internal_authz.MemberTypeString),
hook.Base64ToBytesHookFunc(),
hook.TagToLanguageHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
database.DecodeHook,
hook.EnumHookFunc(authz.MemberTypeString),
actions.HTTPConfigDecodeHook,
hooks.MapTypeStringDecode[string, *authz.SystemAPIUser],
)),
)
logging.OnError(err).Fatal("unable to read default config")
@ -82,26 +87,34 @@ func MustNewConfig(v *viper.Viper) *Config {
}
type Steps struct {
s1ProjectionTable *ProjectionTable
s2AssetsTable *AssetTable
FirstInstance *FirstInstance
s5LastFailed *LastFailed
s6OwnerRemoveColumns *OwnerRemoveColumns
s7LogstoreTables *LogstoreTables
s8AuthTokens *AuthTokenIndexes
CorrectCreationDate *CorrectCreationDate
s12AddOTPColumns *AddOTPColumns
s13FixQuotaProjection *FixQuotaConstraints
s14NewEventsTable *NewEventsTable
s15CurrentStates *CurrentProjectionState
s16UniqueConstraintsLower *UniqueConstraintToLower
s17AddOffsetToUniqueConstraints *AddOffsetToCurrentStates
s18AddLowerFieldsToLoginNames *AddLowerFieldsToLoginNames
s19AddCurrentStatesIndex *AddCurrentSequencesIndex
s20AddByUserSessionIndex *AddByUserIndexToSession
s21AddBlockFieldToLimits *AddBlockFieldToLimits
s22ActiveInstancesIndex *ActiveInstanceEvents
s23CorrectGlobalUniqueConstraints *CorrectGlobalUniqueConstraints
s1ProjectionTable *ProjectionTable
s2AssetsTable *AssetTable
FirstInstance *FirstInstance
s5LastFailed *LastFailed
s6OwnerRemoveColumns *OwnerRemoveColumns
s7LogstoreTables *LogstoreTables
s8AuthTokens *AuthTokenIndexes
CorrectCreationDate *CorrectCreationDate
s12AddOTPColumns *AddOTPColumns
s13FixQuotaProjection *FixQuotaConstraints
s14NewEventsTable *NewEventsTable
s15CurrentStates *CurrentProjectionState
s16UniqueConstraintsLower *UniqueConstraintToLower
s17AddOffsetToUniqueConstraints *AddOffsetToCurrentStates
s18AddLowerFieldsToLoginNames *AddLowerFieldsToLoginNames
s19AddCurrentStatesIndex *AddCurrentSequencesIndex
s20AddByUserSessionIndex *AddByUserIndexToSession
s21AddBlockFieldToLimits *AddBlockFieldToLimits
s22ActiveInstancesIndex *ActiveInstanceEvents
s23CorrectGlobalUniqueConstraints *CorrectGlobalUniqueConstraints
s24AddActorToAuthTokens *AddActorToAuthTokens
s25User11AddLowerFieldsToVerifiedEmail *User11AddLowerFieldsToVerifiedEmail
s26AuthUsers3 *AuthUsers3
s27IDPTemplate6SAMLNameIDFormat *IDPTemplate6SAMLNameIDFormat
s28AddFieldTable *AddFieldTable
s29FillFieldsForProjectGrant *FillFieldsForProjectGrant
s30FillFieldsForOrgDomainVerified *FillFieldsForOrgDomainVerified
s31AddAggregateIndexToFields *AddAggregateIndexToFields
}
func MustNewSteps(v *viper.Viper) *Steps {

245
cmd/setup/config_test.go Normal file
View File

@ -0,0 +1,245 @@
package setup
import (
"encoding/base64"
"fmt"
"net/http"
"strings"
"testing"
"github.com/muhlemmer/gu"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
)
func TestMustNewConfig(t *testing.T) {
encodedKey := "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6aStGRlNKTDdmNXl3NEtUd3pnTQpQMzRlUEd5Y20vTStrVDBNN1Y0Q2d4NVYzRWFESXZUUUtUTGZCYUVCNDV6YjlMdGpJWHpEdzByWFJvUzJoTzZ0CmgrQ1lRQ3ozS0N2aDA5QzBJenhaaUIySVMzSC9hVCs1Qng5RUZZK3ZuQWtaamNjYnlHNVlOUnZtdE9sbnZJZUkKSDdxWjB0RXdrUGZGNUdFWk5QSlB0bXkzVUdWN2lvZmRWUVMxeFJqNzMrYU13NXJ2SDREOElkeWlBQzNWZWtJYgpwdDBWajBTVVgzRHdLdG9nMzM3QnpUaVBrM2FYUkYwc2JGaFFvcWRKUkk4TnFnWmpDd2pxOXlmSTV0eXhZc3duCitKR3pIR2RIdlczaWRPRGxtd0V0NUsycGFzaVJJV0syT0dmcSt3MEVjbHRRSGFidXFFUGdabG1oQ2tSZE5maXgKQndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
decodedKey, err := base64.StdEncoding.DecodeString(encodedKey)
if err != nil {
t.Fatal(err)
}
type args struct {
yaml string
}
tests := []struct {
name string
args args
want func(*testing.T, *Config)
}{{
name: "features ok",
args: args{yaml: `
DefaultInstance:
Features:
LoginDefaultOrg: true
LegacyIntrospection: true
TriggerIntrospectionProjections: true
UserSchema: true
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
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),
})
},
}, {
name: "system api users ok",
args: args{yaml: `
SystemAPIUsers:
- superuser:
Memberships:
- MemberType: System
- MemberType: Organization
- MemberType: IAM
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.SystemAPIUsers, map[string]*authz.SystemAPIUser{
"superuser": {
Memberships: authz.Memberships{{
MemberType: authz.MemberTypeSystem,
}, {
MemberType: authz.MemberTypeOrganization,
}, {
MemberType: authz.MemberTypeIAM,
}},
},
})
},
}, {
name: "system api users string ok",
args: args{yaml: fmt.Sprintf(`
SystemAPIUsers: >
{"systemuser": {"path": "/path/to/superuser/key.pem"}, "systemuser2": {"keyData": "%s"}}
Log:
Level: info
Actions:
HTTP:
DenyList: []
`, encodedKey)},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.SystemAPIUsers, map[string]*authz.SystemAPIUser{
"systemuser": {
Path: "/path/to/superuser/key.pem",
},
"systemuser2": {
KeyData: decodedKey,
},
})
},
}, {
name: "headers ok",
args: args{yaml: `
Telemetry:
Headers:
single-value: single-value
multi-value:
- multi-value1
- multi-value2
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.Telemetry.Headers, http.Header{
"single-value": []string{"single-value"},
"multi-value": []string{"multi-value1", "multi-value2"},
})
},
}, {
name: "headers string ok",
args: args{yaml: `
Telemetry:
Headers: >
{"single-value": "single-value", "multi-value": ["multi-value1", "multi-value2"]}
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.Telemetry.Headers, http.Header{
"single-value": []string{"single-value"},
"multi-value": []string{"multi-value1", "multi-value2"},
})
},
}, {
name: "message texts ok",
args: args{yaml: `
DefaultInstance:
MessageTexts:
- MessageTextType: InitCode
Title: foo
- MessageTextType: PasswordReset
Greeting: bar
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.DefaultInstance.MessageTexts, []*domain.CustomMessageText{{
MessageTextType: "InitCode",
Title: "foo",
}, {
MessageTextType: "PasswordReset",
Greeting: "bar",
}})
},
}, {
name: "message texts string ok",
args: args{yaml: `
DefaultInstance:
MessageTexts: >
[{"messageTextType": "InitCode", "title": "foo"}, {"messageTextType": "PasswordReset", "greeting": "bar"}]
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.DefaultInstance.MessageTexts, []*domain.CustomMessageText{{
MessageTextType: "InitCode",
Title: "foo",
}, {
MessageTextType: "PasswordReset",
Greeting: "bar",
}})
},
}, {
name: "roles ok",
args: args{yaml: `
InternalAuthZ:
RolePermissionMappings:
- Role: IAM_OWNER
Permissions:
- iam.write
- Role: ORG_OWNER
Permissions:
- org.write
- org.read
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.InternalAuthZ, authz.Config{
RolePermissionMappings: []authz.RoleMapping{
{Role: "IAM_OWNER", Permissions: []string{"iam.write"}},
{Role: "ORG_OWNER", Permissions: []string{"org.write", "org.read"}},
},
})
},
}, {
name: "roles string ok",
args: args{yaml: `
InternalAuthZ:
RolePermissionMappings: >
[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write", "org.read"]}]
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.InternalAuthZ, authz.Config{
RolePermissionMappings: []authz.RoleMapping{
{Role: "IAM_OWNER", Permissions: []string{"iam.write"}},
{Role: "ORG_OWNER", Permissions: []string{"org.write", "org.read"}},
},
})
},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := viper.New()
v.SetConfigType("yaml")
require.NoError(t, v.ReadConfig(strings.NewReader(tt.args.yaml)))
got := MustNewConfig(v)
tt.want(t, got)
})
}
}

View File

@ -34,6 +34,8 @@ import (
notify_handler "github.com/zitadel/zitadel/internal/notification"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/query/projection"
es_v4 "github.com/zitadel/zitadel/internal/v2/eventstore"
es_v4_pg "github.com/zitadel/zitadel/internal/v2/eventstore/postgres"
"github.com/zitadel/zitadel/internal/webauthn"
)
@ -57,13 +59,16 @@ Requirements:
err = BindInitProjections(cmd)
logging.OnError(err).Fatal("unable to bind \"init-projections\" flag")
err = bindForMirror(cmd)
logging.OnError(err).Fatal("unable to bind \"for-mirror\" flag")
config := MustNewConfig(viper.GetViper())
steps := MustNewSteps(viper.New())
masterKey, err := key.MasterKey(cmd)
logging.OnError(err).Panic("No master key provided")
Setup(config, steps, masterKey)
Setup(cmd.Context(), config, steps, masterKey)
},
}
@ -77,6 +82,7 @@ Requirements:
func Flags(cmd *cobra.Command) {
cmd.PersistentFlags().StringArrayVar(&stepFiles, "steps", nil, "paths to step files to overwrite default steps")
cmd.Flags().Bool("init-projections", viper.GetBool("InitProjections"), "beta feature: initializes projections after they are created, allows smooth start as projections are up to date")
cmd.Flags().Bool("for-mirror", viper.GetBool("ForMirror"), "use this flag if you want to mirror your existing data")
key.AddMasterKeyFlag(cmd)
tls.AddTLSModeFlag(cmd)
}
@ -85,8 +91,11 @@ func BindInitProjections(cmd *cobra.Command) error {
return viper.BindPFlag("InitProjections.Enabled", cmd.Flags().Lookup("init-projections"))
}
func Setup(config *Config, steps *Steps, masterKey string) {
ctx := context.Background()
func bindForMirror(cmd *cobra.Command) error {
return viper.BindPFlag("ForMirror", cmd.Flags().Lookup("for-mirror"))
}
func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) {
logging.Info("setup started")
i18n.MustLoadSupportedLanguagesFromDir()
@ -99,13 +108,20 @@ func Setup(config *Config, steps *Steps, masterKey string) {
logging.OnError(err).Fatal("unable to connect to database")
config.Eventstore.Querier = old_es.NewCRDB(queryDBClient)
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient)
esV3 := new_es.NewEventstore(esPusherDBClient)
config.Eventstore.Pusher = esV3
config.Eventstore.Searcher = esV3
eventstoreClient := eventstore.NewEventstore(config.Eventstore)
logging.OnError(err).Fatal("unable to start eventstore")
eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(queryDBClient, &es_v4_pg.Config{
MaxRetries: config.Eventstore.MaxRetries,
}))
steps.s1ProjectionTable = &ProjectionTable{dbClient: queryDBClient.DB}
steps.s2AssetsTable = &AssetTable{dbClient: queryDBClient.DB}
steps.FirstInstance.Skip = config.ForMirror || steps.FirstInstance.Skip
steps.FirstInstance.instanceSetup = config.DefaultInstance
steps.FirstInstance.userEncryptionKey = config.EncryptionKeys.User
steps.FirstInstance.smtpEncryptionKey = config.EncryptionKeys.SMTP
@ -136,6 +152,14 @@ func Setup(config *Config, steps *Steps, masterKey string) {
steps.s21AddBlockFieldToLimits = &AddBlockFieldToLimits{dbClient: queryDBClient}
steps.s22ActiveInstancesIndex = &ActiveInstanceEvents{dbClient: queryDBClient}
steps.s23CorrectGlobalUniqueConstraints = &CorrectGlobalUniqueConstraints{dbClient: esPusherDBClient}
steps.s24AddActorToAuthTokens = &AddActorToAuthTokens{dbClient: queryDBClient}
steps.s25User11AddLowerFieldsToVerifiedEmail = &User11AddLowerFieldsToVerifiedEmail{dbClient: esPusherDBClient}
steps.s26AuthUsers3 = &AuthUsers3{dbClient: esPusherDBClient}
steps.s27IDPTemplate6SAMLNameIDFormat = &IDPTemplate6SAMLNameIDFormat{dbClient: esPusherDBClient}
steps.s28AddFieldTable = &AddFieldTable{dbClient: esPusherDBClient}
steps.s29FillFieldsForProjectGrant = &FillFieldsForProjectGrant{eventstore: eventstoreClient}
steps.s30FillFieldsForOrgDomainVerified = &FillFieldsForOrgDomainVerified{eventstore: eventstoreClient}
steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: esPusherDBClient}
err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil)
logging.OnError(err).Fatal("unable to start projections")
@ -158,6 +182,8 @@ func Setup(config *Config, steps *Steps, masterKey string) {
steps.s14NewEventsTable,
steps.s1ProjectionTable,
steps.s2AssetsTable,
steps.s28AddFieldTable,
steps.s31AddAggregateIndexToFields,
steps.FirstInstance,
steps.s5LastFailed,
steps.s6OwnerRemoveColumns,
@ -172,6 +198,10 @@ func Setup(config *Config, steps *Steps, masterKey string) {
steps.s20AddByUserSessionIndex,
steps.s22ActiveInstancesIndex,
steps.s23CorrectGlobalUniqueConstraints,
steps.s24AddActorToAuthTokens,
steps.s26AuthUsers3,
steps.s29FillFieldsForProjectGrant,
steps.s30FillFieldsForOrgDomainVerified,
} {
mustExecuteMigration(ctx, eventstoreClient, step, "migration failed")
}
@ -184,15 +214,18 @@ func Setup(config *Config, steps *Steps, masterKey string) {
for _, step := range []migration.Migration{
steps.s18AddLowerFieldsToLoginNames,
steps.s21AddBlockFieldToLimits,
steps.s25User11AddLowerFieldsToVerifiedEmail,
steps.s27IDPTemplate6SAMLNameIDFormat,
} {
mustExecuteMigration(ctx, eventstoreClient, step, "migration failed")
}
// projection initialization must be done last, since the steps above might add required columns to the projections
if config.InitProjections.Enabled {
if !config.ForMirror && config.InitProjections.Enabled {
initProjections(
ctx,
eventstoreClient,
eventstoreV4,
queryDBClient,
projectionDBClient,
masterKey,
@ -214,6 +247,7 @@ func readStmt(fs embed.FS, folder, typ, filename string) (string, error) {
func initProjections(
ctx context.Context,
eventstoreClient *eventstore.Eventstore,
eventstoreV4 *es_v4.EventStore,
queryDBClient,
projectionDBClient *database.DB,
masterKey string,
@ -270,6 +304,7 @@ func initProjections(
queries, err := query.StartQueries(
ctx,
eventstoreClient,
eventstoreV4.Querier,
queryDBClient,
projectionDBClient,
config.Projections,

View File

@ -1,5 +1,7 @@
# By using the FirstInstance section, you can overwrite the DefaultInstance configuration for the first instance created by zitadel setup.
FirstInstance:
# If set to true zitadel is setup without initial data
Skip: false
# The machine key from the section FirstInstance.Org.Machine.MachineKey is written to the MachineKeyPath.
MachineKeyPath: # ZITADEL_FIRSTINSTANCE_MACHINEKEYPATH
# The personal access token from the section FirstInstance.Org.Machine.Pat is written to the PatPath.
@ -9,8 +11,7 @@ FirstInstance:
Org:
Name: ZITADEL # ZITADEL_FIRSTINSTANCE_ORG_NAME
# In the FirstInstance.Org.Human section, the initial organization's admin user with the role IAM_OWNER is defined.
# ZITADEL either creates a human user or a machine user.
# If FirstInstance.Org.Machine.Machine is defined, a service user is created with the IAM_OWNER role, not a human user.
# If FirstInstance.Org.Machine.Machine is defined, a service user is created with the IAM_OWNER role.
Human:
# In case UserLoginMustBeDomain is false (default) and you don't overwrite the username with an email,
# it will be suffixed by the org domain (org-name + domain from config).
@ -32,8 +33,7 @@ FirstInstance:
Password: Password1! # ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD
PasswordChangeRequired: true # ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED
# In the FirstInstance.Org.Machine section, the initial organization's admin user with the role IAM_OWNER is defined.
# ZITADEL either creates a human user or a machine user.
# If FirstInstance.Org.Machine.Machine is defined, a service user is created with the IAM_OWNER role, not a human user.
# If FirstInstance.Org.Machine.Machine is defined, a service user is created with the IAM_OWNER role.
Machine:
Machine:
Username: # ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME
@ -52,3 +52,6 @@ CorrectCreationDate:
AddEventCreatedAt:
BulkAmount: 100 # ZITADEL_ADDEVENTCREATEDAT_BULKAMOUNT
FillFields:
BatchSize: 1000 # ZITADEL_EVENTSTORE_FILLFIELDS_BULKLIMIT

View File

@ -85,19 +85,19 @@ func MustNewConfig(v *viper.Viper) *Config {
err := v.Unmarshal(config,
viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
hooks.SliceTypeStringDecode[*domain.CustomMessageText],
hooks.SliceTypeStringDecode[*command.SetQuota],
hooks.SliceTypeStringDecode[internal_authz.RoleMapping],
hooks.MapTypeStringDecode[string, *internal_authz.SystemAPIUser],
hooks.MapTypeStringDecode[domain.Feature, any],
hooks.MapHTTPHeaderStringDecode,
database.DecodeHook,
actions.HTTPConfigDecodeHook,
hook.EnumHookFunc(internal_authz.MemberTypeString),
hooks.MapTypeStringDecode[domain.Feature, any],
hooks.SliceTypeStringDecode[*command.SetQuota],
hook.Base64ToBytesHookFunc(),
hook.TagToLanguageHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
database.DecodeHook,
actions.HTTPConfigDecodeHook,
hook.EnumHookFunc(internal_authz.MemberTypeString),
)),
)
logging.OnError(err).Fatal("unable to read config")

View File

@ -223,6 +223,52 @@ Actions:
Greeting: "bar",
}})
},
}, {
name: "roles ok",
args: args{yaml: `
InternalAuthZ:
RolePermissionMappings:
- Role: IAM_OWNER
Permissions:
- iam.write
- Role: ORG_OWNER
Permissions:
- org.write
- org.read
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.InternalAuthZ, authz.Config{
RolePermissionMappings: []authz.RoleMapping{
{Role: "IAM_OWNER", Permissions: []string{"iam.write"}},
{Role: "ORG_OWNER", Permissions: []string{"org.write", "org.read"}},
},
})
},
}, {
name: "roles string ok",
args: args{yaml: `
InternalAuthZ:
RolePermissionMappings: >
[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write", "org.read"]}]
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.InternalAuthZ, authz.Config{
RolePermissionMappings: []authz.RoleMapping{
{Role: "IAM_OWNER", Permissions: []string{"iam.write"}},
{Role: "ORG_OWNER", Permissions: []string{"org.write", "org.read"}},
},
})
},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -4,7 +4,6 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/cmd/key"

View File

@ -34,18 +34,24 @@ import (
"github.com/zitadel/zitadel/internal/api"
"github.com/zitadel/zitadel/internal/api/assets"
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
action_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/action/v3alpha"
"github.com/zitadel/zitadel/internal/api/grpc/admin"
"github.com/zitadel/zitadel/internal/api/grpc/auth"
execution_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/execution/v3alpha"
"github.com/zitadel/zitadel/internal/api/grpc/feature/v2"
feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2"
feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta"
"github.com/zitadel/zitadel/internal/api/grpc/management"
oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2"
"github.com/zitadel/zitadel/internal/api/grpc/org/v2"
"github.com/zitadel/zitadel/internal/api/grpc/session/v2"
"github.com/zitadel/zitadel/internal/api/grpc/settings/v2"
oidc_v2beta "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2beta"
org_v2 "github.com/zitadel/zitadel/internal/api/grpc/org/v2"
org_v2beta "github.com/zitadel/zitadel/internal/api/grpc/org/v2beta"
session_v2 "github.com/zitadel/zitadel/internal/api/grpc/session/v2"
session_v2beta "github.com/zitadel/zitadel/internal/api/grpc/session/v2beta"
settings_v2 "github.com/zitadel/zitadel/internal/api/grpc/settings/v2"
settings_v2beta "github.com/zitadel/zitadel/internal/api/grpc/settings/v2beta"
"github.com/zitadel/zitadel/internal/api/grpc/system"
user_schema_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/user/schema/v3alpha"
user_v2 "github.com/zitadel/zitadel/internal/api/grpc/user/v2"
user_v2beta "github.com/zitadel/zitadel/internal/api/grpc/user/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"
@ -78,6 +84,8 @@ import (
"github.com/zitadel/zitadel/internal/notification"
"github.com/zitadel/zitadel/internal/query"
"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"
"github.com/zitadel/zitadel/internal/webauthn"
"github.com/zitadel/zitadel/openapi"
)
@ -151,14 +159,19 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
}
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient)
config.Eventstore.Searcher = new_es.NewEventstore(queryDBClient)
config.Eventstore.Querier = old_es.NewCRDB(queryDBClient)
eventstoreClient := eventstore.NewEventstore(config.Eventstore)
eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(queryDBClient, &es_v4_pg.Config{
MaxRetries: config.Eventstore.MaxRetries,
}))
sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC)
queries, err := query.StartQueries(
ctx,
eventstoreClient,
eventstoreV4.Querier,
queryDBClient,
projectionDBClient,
config.Projections,
@ -392,23 +405,37 @@ func startAPIs(
if err := apis.RegisterServer(ctx, auth.CreateServer(commands, queries, authRepo, config.SystemDefaults, keys.User, config.ExternalSecure), tlsConfig); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, user_v2beta.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure), idp.SAMLRootURL(config.ExternalSecure), assets.AssetAPI(config.ExternalSecure), permissionCheck)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure), idp.SAMLRootURL(config.ExternalSecure), assets.AssetAPI(config.ExternalSecure), permissionCheck)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil {
if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, settings.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
if err := apis.RegisterService(ctx, settings_v2beta.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, org.CreateServer(commands, queries, permissionCheck)); err != nil {
if err := apis.RegisterService(ctx, org_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, feature.CreateServer(commands, queries)); err != nil {
if err := apis.RegisterService(ctx, feature_v2beta.CreateServer(commands, queries)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, execution_v3_alpha.CreateServer(commands, queries, domain.AllFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil {
if err := apis.RegisterService(ctx, session_v2.CreateServer(commands, queries)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, settings_v2.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, org_v2.CreateServer(commands, queries, permissionCheck)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, feature_v2.CreateServer(commands, queries)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, action_v3_alpha.CreateServer(commands, queries, domain.AllFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, user_schema_v3_alpha.CreateServer(commands, queries)); err != nil {
@ -439,7 +466,7 @@ func startAPIs(
}
apis.RegisterHandlerOnPrefix(openapi.HandlerPrefix, openAPIHandler)
oidcServer, err := oidc.NewServer(config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor, config.Log.Slog())
oidcServer, err := oidc.NewServer(ctx, config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor, config.Log.Slog(), config.SystemDefaults.SecretHasher)
if err != nil {
return nil, fmt.Errorf("unable to start oidc provider: %w", err)
}
@ -484,6 +511,9 @@ func startAPIs(
apis.HandleFunc(login.EndpointDeviceAuth, login.RedirectDeviceAuthToPrefix)
// After OIDC provider so that the callback endpoint can be used
if err := apis.RegisterService(ctx, oidc_v2beta.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil {
return nil, err
}
@ -544,15 +574,17 @@ func showBasicInformation(startConfig *Config) {
consoleURL := fmt.Sprintf("%s://%s:%v/ui/console\n", http, startConfig.ExternalDomain, startConfig.ExternalPort)
healthCheckURL := fmt.Sprintf("%s://%s:%v/debug/healthz\n", http, startConfig.ExternalDomain, startConfig.ExternalPort)
machineIdMethod := id.MachineIdentificationMethod()
insecure := !startConfig.TLS.Enabled && !startConfig.ExternalSecure
fmt.Printf(" ===============================================================\n\n")
fmt.Printf(" Version : %s\n", build.Version())
fmt.Printf(" TLS enabled : %v\n", startConfig.TLS.Enabled)
fmt.Printf(" External Secure : %v\n", startConfig.ExternalSecure)
fmt.Printf(" Console URL : %s", color.BlueString(consoleURL))
fmt.Printf(" Health Check URL : %s", color.BlueString(healthCheckURL))
fmt.Printf(" Version : %s\n", build.Version())
fmt.Printf(" TLS enabled : %v\n", startConfig.TLS.Enabled)
fmt.Printf(" External Secure : %v\n", startConfig.ExternalSecure)
fmt.Printf(" Machine Id Method : %v\n", machineIdMethod)
fmt.Printf(" Console URL : %s", color.BlueString(consoleURL))
fmt.Printf(" Health Check URL : %s", color.BlueString(healthCheckURL))
if insecure {
fmt.Printf("\n %s: you're using plain http without TLS. Be aware this is \n", color.RedString("Warning"))
fmt.Printf(" not a secure setup and should only be used for test systems. \n")

View File

@ -36,7 +36,7 @@ Requirements:
setupConfig := setup.MustNewConfig(viper.GetViper())
setupSteps := setup.MustNewSteps(viper.New())
setup.Setup(setupConfig, setupSteps, masterKey)
setup.Setup(cmd.Context(), setupConfig, setupSteps, masterKey)
startConfig := MustNewConfig(viper.GetViper())

View File

@ -34,7 +34,7 @@ Requirements:
setupConfig := setup.MustNewConfig(viper.GetViper())
setupSteps := setup.MustNewSteps(viper.New())
setup.Setup(setupConfig, setupSteps, masterKey)
setup.Setup(cmd.Context(), setupConfig, setupSteps, masterKey)
startConfig := MustNewConfig(viper.GetViper())

View File

@ -15,6 +15,7 @@ import (
"github.com/zitadel/zitadel/cmd/build"
"github.com/zitadel/zitadel/cmd/initialise"
"github.com/zitadel/zitadel/cmd/key"
"github.com/zitadel/zitadel/cmd/mirror"
"github.com/zitadel/zitadel/cmd/ready"
"github.com/zitadel/zitadel/cmd/setup"
"github.com/zitadel/zitadel/cmd/start"
@ -55,6 +56,7 @@ func New(out io.Writer, in io.Reader, args []string, server chan<- *start.Server
start.New(server),
start.NewStartFromInit(server),
start.NewStartFromSetup(server),
mirror.New(&configFiles),
key.New(),
ready.New(),
)

View File

@ -159,6 +159,8 @@ export class AppComponent implements OnDestroy {
this.matIconRegistry.addSvgIcon('mdi_jwt', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/jwt.svg'));
this.matIconRegistry.addSvgIcon('mdi_smtp', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/mail.svg'));
this.matIconRegistry.addSvgIcon('mdi_symbol', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/symbol.svg'));
this.matIconRegistry.addSvgIcon(

View File

@ -14,6 +14,7 @@ import localePt from '@angular/common/locales/pt';
import localeZh from '@angular/common/locales/zh';
import localeRu from '@angular/common/locales/ru';
import localeNl from '@angular/common/locales/nl';
import localeSv from '@angular/common/locales/sv';
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { MatNativeDateModule } from '@angular/material/core';
import { MatDialogModule } from '@angular/material/dialog';
@ -99,6 +100,8 @@ registerLocaleData(localeCs);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/cs.json'));
registerLocaleData(localeNl);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/nl.json'));
registerLocaleData(localeSv);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/sv.json'));
export class WebpackTranslateLoader implements TranslateLoader {
getTranslation(lang: string): Observable<any> {

View File

@ -0,0 +1,16 @@
<div class="cnsl-cr-row">
<div class="cnsl-cr-secondary-text" [ngStyle]="{ minWidth: labelMinWidth }">{{ label }}</div>
<button
class="cnsl-cr-copy"
[disabled]="copied === value"
[matTooltip]="(copied !== value ? 'ACTIONS.COPY' : 'ACTIONS.COPIED') | translate"
cnslCopyToClipboard
[valueToCopy]="value"
(copiedValue)="copied = $event"
>
{{ value }}
</button>
<div class="cnsl-cr-item">
<ng-content></ng-content>
</div>
</div>

View File

@ -0,0 +1,41 @@
.cnsl-cr-row {
display: flex;
align-items: center;
.cnsl-cr-right {
flex-shrink: 0;
}
}
@mixin copy-row-theme($theme) {
$is-dark-theme: map-get($theme, is-dark);
$foreground: map-get($theme, foreground);
$button-text-color: map-get($foreground, text);
$button-disabled-text-color: map-get($foreground, disabled-button);
.cnsl-cr-copy {
flex-grow: 1;
text-align: left;
transition: opacity 0.15s ease-in-out;
background-color: #8795a110;
border: 1px solid #8795a160;
border-radius: 4px;
padding: 0.25rem 1rem;
margin: 0.25rem 0rem;
color: $button-text-color;
text-overflow: ellipsis;
overflow: hidden;
cursor: copy;
&[disabled] {
color: $button-disabled-text-color;
}
}
}
.row {
display: flex;
align-items: center;
.right {
flex-shrink: 0;
}
}

View File

@ -0,0 +1,21 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CopyToClipboardModule } from '../../directives/copy-to-clipboard/copy-to-clipboard.module';
@Component({
standalone: true,
selector: 'cnsl-copy-row',
templateUrl: './copy-row.component.html',
styleUrls: ['./copy-row.component.scss'],
imports: [CommonModule, TranslateModule, MatButtonModule, MatTooltipModule, CopyToClipboardModule],
})
export class CopyRowComponent {
@Input({ required: true }) public label = '';
@Input({ required: true }) public value = '';
@Input() public labelMinWidth = '';
public copied = '';
}

View File

@ -0,0 +1,359 @@
<div class="feature-settings-wrapper">
<div class="feature-title-row">
<h2>{{ 'DESCRIPTIONS.SETTINGS.FEATURES.TITLE' | translate }}</h2>
<a
mat-icon-button
href="https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service"
rel="noreferrer"
target="_blank"
>
<mat-icon class="icon">info_outline</mat-icon>
</a>
</div>
<p class="events-desc cnsl-secondary-text">{{ 'DESCRIPTIONS.SETTINGS.FEATURES.DESCRIPTION' | translate }}</p>
<ng-template cnslHasRole [hasRole]="['iam.restrictions.write']">
<button color="warn" (click)="resetSettings()" mat-stroked-button>
{{ 'SETTING.FEATURES.RESET' | translate }}
</button>
</ng-template>
<cnsl-card *ngIf="toggleStates && featureData">
<div class="features">
<div class="feature-row" *ngIf="toggleStates.loginDefaultOrg">
<span>{{ 'SETTING.FEATURES.LOGINDEFAULTORG' | translate }}</span>
<div class="row">
<mat-button-toggle-group
class="theme-toggle"
class="buttongroup"
[(ngModel)]="toggleStates.loginDefaultOrg.state"
(change)="validateAndSave()"
name="displayview"
aria-label="Display View"
>
<mat-button-toggle [value]="ToggleState.INHERITED">
<div class="toggle-row">
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
<i
class="info-i las la-question-circle"
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
></i>
<div
*ngIf="
!!featureData.loginDefaultOrg?.enabled &&
(featureData.loginDefaultOrg?.source === Source.SOURCE_SYSTEM ||
featureData.loginDefaultOrg?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot enabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
></div>
<div
*ngIf="
!featureData.loginDefaultOrg?.enabled &&
(featureData.loginDefaultOrg?.source === Source.SOURCE_SYSTEM ||
featureData.loginDefaultOrg?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot disabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
></div>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.DISABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.ENABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
</div>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<cnsl-info-section class="feature-info">{{
'SETTING.FEATURES.LOGINDEFAULTORG_DESCRIPTION' | translate
}}</cnsl-info-section>
</div>
<div class="feature-row" *ngIf="toggleStates.oidcLegacyIntrospection">
<span>{{ 'SETTING.FEATURES.OIDCLEGACYINTROSPECTION' | translate }}</span>
<div class="row">
<mat-button-toggle-group
class="theme-toggle"
class="buttongroup"
[(ngModel)]="toggleStates.oidcLegacyIntrospection.state"
(change)="validateAndSave()"
name="displayview"
aria-label="Display View"
>
<mat-button-toggle [value]="ToggleState.INHERITED">
<div class="toggle-row">
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
<i
class="info-i las la-question-circle"
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
></i>
<div
*ngIf="
!!featureData.oidcLegacyIntrospection?.enabled &&
(featureData.oidcLegacyIntrospection?.source === Source.SOURCE_SYSTEM ||
featureData.oidcLegacyIntrospection?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot enabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
></div>
<div
*ngIf="
!featureData.oidcLegacyIntrospection?.enabled &&
(featureData.oidcLegacyIntrospection?.source === Source.SOURCE_SYSTEM ||
featureData.oidcLegacyIntrospection?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot disabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
></div>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.DISABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.ENABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
</div>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<cnsl-info-section class="feature-info">{{
'SETTING.FEATURES.OIDCLEGACYINTROSPECTION_DESCRIPTION' | translate
}}</cnsl-info-section>
</div>
<div class="feature-row" *ngIf="toggleStates.oidcTokenExchange">
<span>{{ 'SETTING.FEATURES.OIDCTOKENEXCHANGE' | translate }}</span>
<div class="row">
<mat-button-toggle-group
class="theme-toggle"
class="buttongroup"
[(ngModel)]="toggleStates.oidcTokenExchange.state"
(change)="validateAndSave()"
name="displayview"
aria-label="Display View"
>
<mat-button-toggle [value]="ToggleState.INHERITED">
<div class="toggle-row">
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
<i
class="info-i las la-question-circle"
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
></i>
<div
*ngIf="
!!featureData.oidcTokenExchange?.enabled &&
(featureData.oidcTokenExchange?.source === Source.SOURCE_SYSTEM ||
featureData.oidcTokenExchange?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot enabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
></div>
<div
*ngIf="
!featureData.oidcTokenExchange?.enabled &&
(featureData.oidcTokenExchange?.source === Source.SOURCE_SYSTEM ||
featureData.oidcTokenExchange?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot disabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
></div>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.DISABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.ENABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
</div>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<cnsl-info-section class="feature-info">{{
'SETTING.FEATURES.OIDCTOKENEXCHANGE_DESCRIPTION' | translate
}}</cnsl-info-section>
</div>
<div class="feature-row" *ngIf="toggleStates.oidcTriggerIntrospectionProjections">
<span>{{ 'SETTING.FEATURES.OIDCTRIGGERINTROSPECTIONPROJECTIONS' | translate }}</span>
<div class="row">
<mat-button-toggle-group
class="theme-toggle"
class="buttongroup"
[(ngModel)]="toggleStates.oidcTriggerIntrospectionProjections.state"
(change)="validateAndSave()"
name="displayview"
aria-label="Display View"
>
<mat-button-toggle [value]="ToggleState.INHERITED">
<div class="toggle-row">
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
<i
class="info-i las la-question-circle"
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
></i>
<div
*ngIf="
!!featureData.oidcTriggerIntrospectionProjections?.enabled &&
(featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_SYSTEM ||
featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot enabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
></div>
<div
*ngIf="
!featureData.oidcTriggerIntrospectionProjections?.enabled &&
(featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_SYSTEM ||
featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot disabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
></div>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.DISABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.ENABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
</div>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<cnsl-info-section class="feature-info">{{
'SETTING.FEATURES.OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION' | translate
}}</cnsl-info-section>
</div>
<div class="feature-row" *ngIf="toggleStates.userSchema">
<span>{{ 'SETTING.FEATURES.USERSCHEMA' | translate }}</span>
<div class="row">
<mat-button-toggle-group
class="theme-toggle"
class="buttongroup"
[(ngModel)]="toggleStates.userSchema.state"
(change)="validateAndSave()"
name="displayview"
aria-label="Display View"
>
<mat-button-toggle [value]="ToggleState.INHERITED">
<div class="toggle-row">
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
<i
class="info-i las la-question-circle"
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
></i>
<div
*ngIf="
!!featureData.userSchema?.enabled &&
(featureData.userSchema?.source === Source.SOURCE_SYSTEM ||
featureData.userSchema?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot enabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
></div>
<div
*ngIf="
!featureData.userSchema?.enabled &&
(featureData.userSchema?.source === Source.SOURCE_SYSTEM ||
featureData.userSchema?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot disabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
></div>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.DISABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.ENABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
</div>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<cnsl-info-section class="feature-info">{{
'SETTING.FEATURES.USERSCHEMA_DESCRIPTION' | translate
}}</cnsl-info-section>
</div>
<div class="feature-row" *ngIf="toggleStates.actions">
<span>{{ 'SETTING.FEATURES.ACTIONS' | translate }}</span>
<div class="row">
<mat-button-toggle-group
class="theme-toggle"
class="buttongroup"
[(ngModel)]="toggleStates.actions.state"
(change)="validateAndSave()"
name="displayview"
aria-label="Display View"
>
<mat-button-toggle [value]="ToggleState.INHERITED">
<div class="toggle-row">
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
<i
class="info-i las la-question-circle"
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
></i>
<div
*ngIf="
!!featureData.actions?.enabled &&
(featureData.actions?.source === Source.SOURCE_SYSTEM ||
featureData.actions?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot enabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
></div>
<div
*ngIf="
!featureData.actions?.enabled &&
(featureData.actions?.source === Source.SOURCE_SYSTEM ||
featureData.actions?.source === Source.SOURCE_UNSPECIFIED)
"
class="current-dot disabled"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
></div>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.DISABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.ENABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
</div>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<cnsl-info-section class="feature-info">{{ 'SETTING.FEATURES.ACTIONS_DESCRIPTION' | translate }}</cnsl-info-section>
</div>
</div>
</cnsl-card>
</div>
<ng-template #sourceLabel let-source="source" let-last="last">
<span class="state" *ngIf="source === Source.SOURCE_SYSTEM">
{{ 'SETTING.FEATURES.SOURCE.' + source | translate }}
</span>
</ng-template>

View File

@ -0,0 +1,69 @@
.feature-settings-wrapper {
.feature-title-row {
display: flex;
align-items: center;
h1 {
margin: 0;
}
a .icon {
font-size: 1.2rem;
height: 1.2rem;
line-height: 1.2rem;
}
}
.features {
.feature-row {
display: flex;
flex-direction: column;
.row {
display: flex;
align-items: center;
justify-content: space-between;
.buttongroup {
margin-right: 0.5rem;
margin-top: 0.5rem;
.toggle-row {
display: flex;
align-items: center;
i {
margin-right: 0.5rem;
}
.info-i {
font-size: 1.2rem;
margin-left: 0.5rem;
margin-right: 0;
}
.current-dot {
height: 8px;
width: 8px;
border-radius: 50%;
// background-color: rgb(84, 142, 230);
margin-left: 0.5rem;
&.enabled {
background-color: var(--success);
}
&.disabled {
background-color: var(--warn);
}
}
}
}
}
.feature-info {
margin-bottom: 1rem;
}
}
}
}

View File

@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FeaturesComponent } from './features.component';
describe('FeaturesComponent', () => {
let component: FeaturesComponent;
let fixture: ComponentFixture<FeaturesComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [FeaturesComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FeaturesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,255 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, Subject } from 'rxjs';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { CardModule } from 'src/app/modules/card/card.module';
import { DisplayJsonDialogComponent } from 'src/app/modules/display-json-dialog/display-json-dialog.component';
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { Event } from 'src/app/proto/generated/zitadel/event_pb';
import { Source } from 'src/app/proto/generated/zitadel/feature/v2beta/feature_pb';
import {
GetInstanceFeaturesResponse,
SetInstanceFeaturesRequest,
} from 'src/app/proto/generated/zitadel/feature/v2beta/instance_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { FeatureService } from 'src/app/services/feature.service';
import { ToastService } from 'src/app/services/toast.service';
enum ToggleState {
ENABLED = 'ENABLED',
DISABLED = 'DISABLED',
INHERITED = 'INHERITED',
}
type FeatureState = { source: Source; state: ToggleState };
type ToggleStates = {
loginDefaultOrg?: FeatureState;
oidcTriggerIntrospectionProjections?: FeatureState;
oidcLegacyIntrospection?: FeatureState;
userSchema?: FeatureState;
oidcTokenExchange?: FeatureState;
actions?: FeatureState;
};
@Component({
imports: [
CommonModule,
FormsModule,
MatButtonToggleModule,
HasRolePipeModule,
MatIconModule,
CardModule,
TranslateModule,
MatButtonModule,
MatCheckboxModule,
InfoSectionModule,
MatTooltipModule,
HasRoleModule,
],
standalone: true,
selector: 'cnsl-features',
templateUrl: './features.component.html',
styleUrls: ['./features.component.scss'],
})
export class FeaturesComponent implements OnDestroy {
private destroy$: Subject<void> = new Subject();
public _loading: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public featureData: GetInstanceFeaturesResponse.AsObject | undefined = undefined;
public toggleStates: ToggleStates | undefined = undefined;
public Source: any = Source;
public ToggleState: any = ToggleState;
constructor(
private featureService: FeatureService,
private breadcrumbService: BreadcrumbService,
private toast: ToastService,
private dialog: MatDialog,
) {
const breadcrumbs = [
new Breadcrumb({
type: BreadcrumbType.INSTANCE,
name: 'Instance',
routerLink: ['/instance'],
}),
];
this.breadcrumbService.setBreadcrumb(breadcrumbs);
this.getFeatures(true);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public openDialog(event: Event): void {
this.dialog.open(DisplayJsonDialogComponent, {
data: {
event: event,
},
width: '450px',
});
}
public validateAndSave() {
this.featureService.resetInstanceFeatures().then(() => {
const req = new SetInstanceFeaturesRequest();
let changed = false;
console.log(this.toggleStates);
if (this.toggleStates?.loginDefaultOrg?.state !== ToggleState.INHERITED) {
req.setLoginDefaultOrg(this.toggleStates?.loginDefaultOrg?.state === ToggleState.ENABLED);
changed = true;
}
if (this.toggleStates?.oidcTriggerIntrospectionProjections?.state !== ToggleState.INHERITED) {
req.setOidcTriggerIntrospectionProjections(
this.toggleStates?.oidcTriggerIntrospectionProjections?.state === ToggleState.ENABLED,
);
changed = true;
}
if (this.toggleStates?.oidcLegacyIntrospection?.state !== ToggleState.INHERITED) {
req.setOidcLegacyIntrospection(this.toggleStates?.oidcLegacyIntrospection?.state === ToggleState.ENABLED);
changed = true;
}
if (this.toggleStates?.userSchema?.state !== ToggleState.INHERITED) {
req.setUserSchema(this.toggleStates?.userSchema?.state === ToggleState.ENABLED);
changed = true;
}
if (this.toggleStates?.oidcTokenExchange?.state !== ToggleState.INHERITED) {
req.setOidcTokenExchange(this.toggleStates?.oidcTokenExchange?.state === ToggleState.ENABLED);
changed = true;
}
if (this.toggleStates?.actions?.state !== ToggleState.INHERITED) {
req.setActions(this.toggleStates?.actions?.state === ToggleState.ENABLED);
changed = true;
}
if (changed) {
this.featureService
.setInstanceFeatures(req)
.then(() => {
this.toast.showInfo('POLICY.TOAST.SET', true);
})
.catch((error) => {
this.toast.showError(error);
});
}
});
}
private getFeatures(inheritance: boolean) {
this.featureService.getInstanceFeatures(inheritance).then((instanceFeaturesResponse) => {
this.featureData = instanceFeaturesResponse.toObject();
console.log(this.featureData);
this.toggleStates = {
loginDefaultOrg: {
source: this.featureData.loginDefaultOrg?.source || Source.SOURCE_SYSTEM,
state:
this.featureData.loginDefaultOrg?.source === Source.SOURCE_SYSTEM ||
this.featureData.loginDefaultOrg?.source === Source.SOURCE_UNSPECIFIED
? ToggleState.INHERITED
: !!this.featureData.loginDefaultOrg?.enabled
? ToggleState.ENABLED
: ToggleState.DISABLED,
},
oidcTriggerIntrospectionProjections: {
source: this.featureData.oidcTriggerIntrospectionProjections?.source || Source.SOURCE_SYSTEM,
state:
this.featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_SYSTEM ||
this.featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_UNSPECIFIED
? ToggleState.INHERITED
: !!this.featureData.oidcTriggerIntrospectionProjections?.enabled
? ToggleState.ENABLED
: ToggleState.DISABLED,
},
oidcLegacyIntrospection: {
source: this.featureData.oidcLegacyIntrospection?.source || Source.SOURCE_SYSTEM,
state:
this.featureData.oidcLegacyIntrospection?.source === Source.SOURCE_SYSTEM ||
this.featureData.oidcLegacyIntrospection?.source === Source.SOURCE_UNSPECIFIED
? ToggleState.INHERITED
: !!this.featureData.oidcLegacyIntrospection?.enabled
? ToggleState.ENABLED
: ToggleState.DISABLED,
},
userSchema: {
source: this.featureData.userSchema?.source || Source.SOURCE_SYSTEM,
state:
this.featureData.userSchema?.source === Source.SOURCE_SYSTEM ||
this.featureData.userSchema?.source === Source.SOURCE_UNSPECIFIED
? ToggleState.INHERITED
: !!this.featureData.userSchema?.enabled
? ToggleState.ENABLED
: ToggleState.DISABLED,
},
oidcTokenExchange: {
source: this.featureData.oidcTokenExchange?.source || Source.SOURCE_SYSTEM,
state:
this.featureData.oidcTokenExchange?.source === Source.SOURCE_SYSTEM ||
this.featureData.oidcTokenExchange?.source === Source.SOURCE_UNSPECIFIED
? ToggleState.INHERITED
: !!this.featureData.oidcTokenExchange?.enabled
? ToggleState.ENABLED
: ToggleState.DISABLED,
},
actions: {
source: Source.SOURCE_SYSTEM,
state:
this.featureData.actions?.source === Source.SOURCE_SYSTEM ||
this.featureData.actions?.source === Source.SOURCE_UNSPECIFIED
? ToggleState.INHERITED
: !!this.featureData.actions?.enabled
? ToggleState.ENABLED
: ToggleState.DISABLED,
},
};
});
}
public resetSettings(): void {
this.featureService
.resetInstanceFeatures()
.then(() => {
this.toast.showInfo('POLICY.TOAST.RESETSUCCESS', true);
setTimeout(() => {
this.getFeatures(true);
}, 1000);
})
.catch((error) => {
this.toast.showError(error);
});
}
public saveFeatures(): void {
if (this.featureData) {
const req = new SetInstanceFeaturesRequest();
req.setLoginDefaultOrg(!!this.featureData.loginDefaultOrg?.enabled);
req.setOidcLegacyIntrospection(!!this.featureData.oidcLegacyIntrospection?.enabled);
req.setOidcTokenExchange(!!this.featureData.oidcTokenExchange?.enabled);
req.setOidcTriggerIntrospectionProjections(!!this.featureData.oidcTriggerIntrospectionProjections?.enabled);
req.setUserSchema(!!this.featureData.userSchema?.enabled);
this.featureService
.setInstanceFeatures(req)
.then(() => {
this.toast.showInfo('POLICY.TOAST.SET', true);
})
.catch((error) => {
this.toast.showError(error);
});
}
}
}

View File

@ -1,6 +1,7 @@
import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({
standalone: true,
selector: '[cnslScrollable]',
})
export class ScrollableDirective {
@ -15,7 +16,6 @@ export class ScrollableDirective {
const top = event.target.scrollTop;
const height = this.el.nativeElement.scrollHeight;
const offset = this.el.nativeElement.offsetHeight;
// emit bottom event
if (top > height - offset - 1) {
this.scrollPosition.emit('bottom');

View File

@ -1,11 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ScrollableDirective } from './scrollable.directive';
@NgModule({
declarations: [ScrollableDirective],
imports: [CommonModule],
exports: [ScrollableDirective],
})
export class ScrollableModule {}

View File

@ -6,7 +6,6 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { ScrollableModule } from 'src/app/directives/scrollable/scrollable.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module';
import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module';
@ -18,7 +17,6 @@ import { ChangesComponent } from './changes.component';
declarations: [ChangesComponent],
imports: [
CommonModule,
ScrollableModule,
MatProgressSpinnerModule,
TranslateModule,
MatIconModule,
@ -30,6 +28,6 @@ import { ChangesComponent } from './changes.component';
MatTooltipModule,
AvatarModule,
],
exports: [ChangesComponent, ScrollableModule],
exports: [ChangesComponent],
})
export class ChangesModule {}

View File

@ -34,7 +34,7 @@
<button mat-stroked-button type="button" (click)="reset()">{{ 'ACTIONS.RESET' | translate }}</button>
<span class="filter-middle">{{ 'FILTER.TITLE' | translate }}</span>
<button mat-raised-button color="primary" type="button" (click)="finish()" data-e2e="filter-finish-button">
{{ 'ACTIONS.FINISH' | translate }}
{{ 'ACTIONS.APPLY' | translate }}
</button>
</div>
<form *ngIf="form" [formGroup]="form" (ngSubmit)="emitChange()">

View File

@ -20,7 +20,7 @@
flex-direction: column;
padding: 0.5rem 0;
min-width: 360px;
max-width: 360px;
max-width: 450px;
padding-bottom: 0.5rem;
position: relative;
color: map-get($foreground, text);
@ -82,7 +82,7 @@
}
.aggregate-type-select {
min-width: 100px;
min-width: 250px;
margin-right: 0.5rem;
}

View File

@ -23,7 +23,7 @@
<div class="filter-top">
<button mat-stroked-button (click)="resetted.emit()">{{ 'ACTIONS.RESET' | translate }}</button>
<span class="filter-middle">{{ 'FILTER.TITLE' | translate }}</span>
<button mat-raised-button color="primary" (click)="emitFilter()">{{ 'ACTIONS.FINISH' | translate }}</button>
<button mat-raised-button color="primary" (click)="emitFilter()">{{ 'ACTIONS.APPLY' | translate }}</button>
</div>
<ng-content></ng-content>
</div>

View File

@ -1,11 +1,11 @@
<div class="footer-wrapper">
<div class="footer-row">
<div class="footer-links">
<a target="_blank" *ngIf="policy?.tosLink" rel="noreferrer" [href]="policy?.tosLink" external>
<div class="footer-links" *ngIf="authService.privacypolicy | async as pP">
<a target="_blank" *ngIf="pP?.tosLink" rel="noreferrer" [href]="pP?.tosLink" external>
<span>{{ 'FOOTER.LINKS.TOS' | translate }}</span>
<i class="las la-external-link-alt"></i>
</a>
<a target="_blank" *ngIf="policy?.privacyLink" rel="noreferrer" [href]="policy?.privacyLink" external>
<a target="_blank" *ngIf="pP?.privacyLink" rel="noreferrer" [href]="pP?.privacyLink" external>
<span>{{ 'FOOTER.LINKS.PP' | translate }}</span>
<i class="las la-external-link-alt"></i>
</a>

View File

@ -8,16 +8,7 @@ import { faXTwitter } from '@fortawesome/free-brands-svg-icons';
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss'],
})
export class FooterComponent implements OnInit {
public policy?: PrivacyPolicy.AsObject;
export class FooterComponent {
public faXTwitter = faXTwitter;
constructor(public authService: GrpcAuthService) {}
ngOnInit(): void {
this.authService.getMyPrivacyPolicy().then((policyResp) => {
if (policyResp.policy) {
this.policy = policyResp.policy;
}
});
}
}

View File

@ -53,6 +53,10 @@
overflow: hidden; // prevents multi-line errors from overlapping the control
}
mat-dialog-container .cnsl-form-field-subscript-wrapper {
width: auto;
}
.cnsl-form-field-hint-wrapper,
.cnsl-form-field-error-wrapper {
display: flex;

View File

@ -168,7 +168,11 @@
<span class="fill-space"></span>
<a class="doc-link" href="https://zitadel.com/docs" mat-stroked-button target="_blank">
<a class="custom-link" *ngIf="customLink && customLinkText" href="{{ customLink }}" mat-stroked-button target="_blank">
{{ customLinkText }}
</a>
<a class="doc-link" *ngIf="docsLink" href="{{ docsLink }}" mat-stroked-button target="_blank">
{{ 'MENU.DOCUMENTATION' | translate }}
</a>

View File

@ -224,7 +224,8 @@
flex: 1;
}
.doc-link {
.doc-link,
.custom-link {
margin-right: 1rem;
@media only screen and (max-width: 800px) {
@ -247,12 +248,16 @@
display: none;
}
@media only screen and (min-width: 600px) {
display: inline;
display: inline;
i {
margin-right: -0.5rem;
margin-left: 0.25rem;
i {
margin-right: -0.5rem;
margin-left: 0.25rem;
}
@media only screen and (max-width: 375px) {
.iam-label {
font-size: x-small;
}
}
}

View File

@ -1,5 +1,5 @@
import { ConnectedPosition, ConnectionPositionPair } from '@angular/cdk/overlay';
import { Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
@ -8,8 +8,8 @@ import { AuthenticationService } from 'src/app/services/authentication.service';
import { BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ActionKeysType } from '../action-keys/action-keys.component';
import { GetPrivacyPolicyResponse } from 'src/app/proto/generated/zitadel/management_pb';
@Component({
selector: 'cnsl-header',
@ -31,6 +31,9 @@ export class HeaderComponent implements OnDestroy {
private destroy$: Subject<void> = new Subject();
public BreadcrumbType: any = BreadcrumbType;
public ActionKeysType: any = ActionKeysType;
public docsLink = 'https://zitadel.com/docs';
public customLink = '';
public customLinkText = '';
public positions: ConnectedPosition[] = [
new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }, 0, 10),
@ -47,7 +50,25 @@ export class HeaderComponent implements OnDestroy {
public mgmtService: ManagementService,
public breadcrumbService: BreadcrumbService,
public router: Router,
) {}
) {
this.loadData();
}
public async loadData(): Promise<any> {
const getData = (): Promise<GetPrivacyPolicyResponse.AsObject> => {
return this.mgmtService.getPrivacyPolicy();
};
getData()
.then((resp) => {
if (resp.policy) {
this.docsLink = resp.policy.docsLink;
this.customLink = resp.policy.customLink;
this.customLinkText = resp.policy.customLinkText;
}
})
.catch(() => {});
}
public ngOnDestroy() {
this.destroy$.next();

View File

@ -87,7 +87,7 @@
</div>
<div class="idp-table-provider-type" *ngSwitchCase="ProviderType.PROVIDER_TYPE_SAML">
<img class="idp-logo" src="./assets/images/idp/saml-icon.svg" alt="saml" />
SAML SP
SAML
</div>
<div class="idp-table-provider-type" *ngSwitchDefault>coming soon</div>
</div>

View File

@ -5,7 +5,7 @@ import { MatTableDataSource } from '@angular/material/table';
import { Router, RouterLink } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Duration } from 'google-protobuf/google/protobuf/duration_pb';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { BehaviorSubject, firstValueFrom, Observable, Subject } from 'rxjs';
import {
ListProvidersRequest as AdminListProvidersRequest,
ListProvidersResponse as AdminListProvidersResponse,
@ -36,6 +36,8 @@ import { ContextChangedWorkflowOverlays } from 'src/app/services/overlay/workflo
import { PageEvent, PaginatorComponent } from '../paginator/paginator.component';
import { PolicyComponentServiceType } from '../policies/policy-component-types.enum';
import { WarnDialogComponent } from '../warn-dialog/warn-dialog.component';
import { LoginPolicyService } from '../../services/login-policy.service';
import { first } from 'rxjs/operators';
@Component({
selector: 'cnsl-idp-table',
@ -70,6 +72,7 @@ export class IdpTableComponent implements OnInit, OnDestroy {
private toast: ToastService,
private dialog: MatDialog,
private router: Router,
private loginPolicySvc: LoginPolicyService,
) {
this.selection.changed.subscribe(() => {
this.changedSelection.emit(this.selection.selected);
@ -298,93 +301,15 @@ export class IdpTableComponent implements OnInit, OnDestroy {
}
}
private addLoginPolicy(): Promise<AddCustomLoginPolicyResponse.AsObject> {
const mgmtreq = new AddCustomLoginPolicyRequest();
mgmtreq.setAllowExternalIdp(this.loginPolicy.allowExternalIdp);
mgmtreq.setAllowRegister(this.loginPolicy.allowRegister);
mgmtreq.setAllowUsernamePassword(this.loginPolicy.allowUsernamePassword);
mgmtreq.setForceMfa(this.loginPolicy.forceMfa);
mgmtreq.setPasswordlessType(this.loginPolicy.passwordlessType);
mgmtreq.setHidePasswordReset(this.loginPolicy.hidePasswordReset);
mgmtreq.setMultiFactorsList(this.loginPolicy.multiFactorsList);
mgmtreq.setSecondFactorsList(this.loginPolicy.secondFactorsList);
const pcl = new Duration()
.setSeconds(this.loginPolicy.passwordCheckLifetime?.seconds ?? 0)
.setNanos(this.loginPolicy.passwordCheckLifetime?.nanos ?? 0);
mgmtreq.setPasswordCheckLifetime(pcl);
const elcl = new Duration()
.setSeconds(this.loginPolicy.externalLoginCheckLifetime?.seconds ?? 0)
.setNanos(this.loginPolicy.externalLoginCheckLifetime?.nanos ?? 0);
mgmtreq.setExternalLoginCheckLifetime(elcl);
const misl = new Duration()
.setSeconds(this.loginPolicy.mfaInitSkipLifetime?.seconds ?? 0)
.setNanos(this.loginPolicy.mfaInitSkipLifetime?.nanos ?? 0);
mgmtreq.setMfaInitSkipLifetime(misl);
const sfcl = new Duration()
.setSeconds(this.loginPolicy.secondFactorCheckLifetime?.seconds ?? 0)
.setNanos(this.loginPolicy.secondFactorCheckLifetime?.nanos ?? 0);
mgmtreq.setSecondFactorCheckLifetime(sfcl);
const mficl = new Duration()
.setSeconds(this.loginPolicy.multiFactorCheckLifetime?.seconds ?? 0)
.setNanos(this.loginPolicy.multiFactorCheckLifetime?.nanos ?? 0);
mgmtreq.setMultiFactorCheckLifetime(mficl);
mgmtreq.setAllowDomainDiscovery(this.loginPolicy.allowDomainDiscovery);
mgmtreq.setIgnoreUnknownUsernames(this.loginPolicy.ignoreUnknownUsernames);
mgmtreq.setDefaultRedirectUri(this.loginPolicy.defaultRedirectUri);
return (this.service as ManagementService).addCustomLoginPolicy(mgmtreq);
}
public addIdp(idp: Provider.AsObject): Promise<any> {
switch (this.serviceType) {
case PolicyComponentServiceType.MGMT:
if (this.isDefault) {
return this.addLoginPolicy()
.then(() => {
this.loginPolicy.isDefault = false;
return (this.service as ManagementService).addIDPToLoginPolicy(idp.id, idp.owner).then(() => {
this.toast.showInfo('IDP.TOAST.ADDED', true);
setTimeout(() => {
this.reloadIDPs$.next();
}, 2000);
});
})
.catch((error) => {
this.toast.showError(error);
});
} else {
return (this.service as ManagementService)
.addIDPToLoginPolicy(idp.id, idp.owner)
.then(() => {
this.toast.showInfo('IDP.TOAST.ADDED', true);
setTimeout(() => {
this.reloadIDPs$.next();
}, 2000);
})
.catch((error) => {
this.toast.showError(error);
});
}
case PolicyComponentServiceType.ADMIN:
return (this.service as AdminService)
.addIDPToLoginPolicy(idp.id)
.then(() => {
this.toast.showInfo('IDP.TOAST.ADDED', true);
setTimeout(() => {
this.reloadIDPs$.next();
}, 2000);
})
.catch((error) => {
this.toast.showError(error);
});
}
return firstValueFrom(this.loginPolicySvc.activateIdp(this.service, idp.id, idp.owner, this.loginPolicy))
.then(() => {
this.toast.showInfo('IDP.TOAST.ADDED', true);
setTimeout(() => {
this.reloadIDPs$.next();
}, 2000);
})
.catch(this.toast.showError);
}
public removeIdp(idp: Provider.AsObject): void {
@ -403,7 +328,8 @@ export class IdpTableComponent implements OnInit, OnDestroy {
switch (this.serviceType) {
case PolicyComponentServiceType.MGMT:
if (this.isDefault) {
this.addLoginPolicy()
this.loginPolicySvc
.createCustomLoginPolicy(this.service as ManagementService, this.loginPolicy)
.then(() => {
this.loginPolicy.isDefault = false;
return (this.service as ManagementService)

View File

@ -1,4 +1,4 @@
<div class="container">
<div class="keyboard-shortcuts">
<h1 class="title" mat-dialog-title>{{ 'KEYBOARDSHORTCUTS.TITLE' | translate }}</h1>
<div mat-dialog-content>
<div *ngIf="isNotOnSystem" class="keyboard-shortcuts-group">

View File

@ -1,19 +1,28 @@
@mixin keyboard-shortcuts-theme($theme) {
$primary: map-get($theme, primary);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$text-color: map-get($foreground, text);
$accent: map-get($theme, accent);
$is-dark-theme: map-get($theme, is-dark);
$accent-color: map-get($primary, 500);
$back: map-get($background, background);
$card-background-color: map-get($background, cards);
.keyboard-shortcuts {
padding: 1.5rem;
border-radius: 6px !important;
.action {
display: flex;
margin-top: 1rem;
button {
border-radius: 0.5rem;
.mat-mdc-button-persistent-ripple {
border-style: none !important;
}
}
.fill-space {
flex: 1;
}
}
.title {
font-size: 1.2rem;
margin-top: 0;
}
.keyboard-shortcuts-group {
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
border: 1px solid $border-color;
background-color: $card-background-color;
padding: 1rem;
border-radius: 0.5rem;
margin: 0.5rem 0 0 0;
@ -21,7 +30,6 @@
h2 {
font-size: 1rem;
margin: 0 0 1rem 0;
color: $text-color;
}
.keyboard-shortcuts-wrapper {
@ -35,14 +43,6 @@
.keyboard-shortcut-name {
font-size: 14px;
strong {
color: $text-color;
}
}
&:not(:last-child) {
border-bottom: 1px solid map-get($foreground, dividers);
}
.fill-space {
@ -68,7 +68,6 @@
right: 0;
bottom: 0;
left: 0;
background: if($is-dark-theme, #fff, #000);
opacity: 0.15;
border-radius: 4px;
}
@ -86,30 +85,51 @@
.keyboard-shortcuts-plus {
font-size: 14px;
}
.title {
font-size: 1.2rem;
margin-top: 0;
color: $text-color;
}
}
.action {
display: flex;
margin-top: 1rem;
button {
border-radius: 0.5rem;
.mat-mdc-button-persistent-ripple {
border-style: none !important;
@mixin keyboard-shortcuts-theme($theme) {
$primary: map-get($theme, primary);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$text-color: map-get($foreground, text);
$accent: map-get($theme, accent);
$is-dark-theme: map-get($theme, is-dark);
$accent-color: map-get($primary, 500);
$back: map-get($background, background);
$card-background-color: map-get($background, cards);
.keyboard-shortcuts {
.title {
color: $text-color;
}
.keyboard-shortcuts-group {
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
border: 1px solid $border-color;
background-color: $card-background-color;
h2 {
color: $text-color;
}
.keyboard-shortcuts-wrapper {
.keyboard-shortcut {
.keyboard-shortcut-name {
strong {
color: $text-color;
}
}
&:not(:last-child) {
border-bottom: 1px solid map-get($foreground, dividers);
}
}
}
}
.keyboard-shortcuts-action-key {
.keyboard-shortcuts-key-overlay {
background: if($is-dark-theme, #fff, #000);
}
}
}
.fill-space {
flex: 1;
}
}
.container {
padding: 1.5rem;
border-radius: 6px !important;
}

View File

@ -61,7 +61,7 @@
<ng-container matColumnDef="expirationDate">
<th mat-header-cell *matHeaderCellDef>{{ 'USER.MACHINE.EXPIRATIONDATE' | translate }}</th>
<td mat-cell *matCellDef="let key">
{{ key.expirationDate | timestampToDate | localizedDate: 'EEE dd. MMM YYYY, HH:mm' }}
{{ key.expirationDate | timestampToDate | localizedDate: 'EEE dd. MMM yyyy, HH:mm' }}
</td>
</ng-container>

View File

@ -96,7 +96,12 @@
[routerLinkActive]="['active']"
[routerLinkActiveOptions]="{ exact: false }"
[routerLink]="['/org-settings']"
*ngIf="(['policy.read'] | hasRole | async) && ((authService.cachedOrgs | async)?.length ?? 1) > 1"
*ngIf="
(['policy.read'] | hasRole | async) &&
((['iam.read$', 'iam.write$'] | hasRole | async) === false ||
(((authService.cachedOrgs | async)?.length ?? 1) > 1 &&
(['iam.read$', 'iam.write$'] | hasRole | async)))
"
>
<span class="label">{{ 'MENU.SETTINGS' | translate }}</span>
</a>

View File

@ -15,7 +15,7 @@
/>
</div>
<div class="org-wrapper">
<div class="org-wrapper" cnslScrollable (scrollPosition)="onNearEndScroll($event)">
<button
class="org-button-with-pin"
mat-button

View File

@ -1,11 +1,14 @@
import { SelectionModel } from '@angular/cdk/collections';
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
import { BehaviorSubject, catchError, debounceTime, finalize, from, map, Observable, of, pipe, tap } from 'rxjs';
import { BehaviorSubject, catchError, debounceTime, finalize, from, map, Observable, of, pipe, scan, take, tap } from 'rxjs';
import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb';
import { Org, OrgNameQuery, OrgQuery, OrgState, OrgStateQuery } from 'src/app/proto/generated/zitadel/org_pb';
import { Org, OrgFieldName, OrgNameQuery, OrgQuery, OrgState, OrgStateQuery } from 'src/app/proto/generated/zitadel/org_pb';
import { AuthenticationService } from 'src/app/services/authentication.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ToastService } from 'src/app/services/toast.service';
const ORG_QUERY_LIMIT = 100;
@Component({
selector: 'cnsl-org-context',
@ -16,7 +19,30 @@ export class OrgContextComponent implements OnInit {
public pinned: SelectionModel<Org.AsObject> = new SelectionModel<Org.AsObject>(true, []);
public orgLoading$: BehaviorSubject<any> = new BehaviorSubject(false);
public orgs$: Observable<Org.AsObject[]> = of([]);
public bottom: boolean = false;
private _done: BehaviorSubject<any> = new BehaviorSubject(false);
private _loading: BehaviorSubject<any> = new BehaviorSubject(false);
public _orgs: BehaviorSubject<Org.AsObject[]> = new BehaviorSubject<Org.AsObject[]>([]);
public orgs$: Observable<Org.AsObject[]> = this._orgs.pipe(
map((orgs) => {
return orgs.sort((left, right) => left.name.localeCompare(right.name));
}),
pipe(
tap((orgs: Org.AsObject[]) => {
this.pinned.clear();
this.getPrefixedItem('pinned-orgs').then((stringifiedOrgs) => {
if (stringifiedOrgs) {
const orgIds: string[] = JSON.parse(stringifiedOrgs);
const pinnedOrgs = orgs.filter((o) => orgIds.includes(o.id));
pinnedOrgs.forEach((o) => this.pinned.select(o));
}
});
}),
),
);
public filterControl: UntypedFormControl = new UntypedFormControl('');
@Input() public org!: Org.AsObject;
@ViewChild('input', { static: false }) input!: ElementRef;
@ -26,15 +52,17 @@ export class OrgContextComponent implements OnInit {
constructor(
public authService: AuthenticationService,
private auth: GrpcAuthService,
private toast: ToastService,
) {
this.filterControl.valueChanges.pipe(debounceTime(500)).subscribe((value) => {
this.loadOrgs(value.trim().toLowerCase());
const filteredValues = this.loadOrgs(0, value.trim().toLowerCase());
this.mapAndUpdate(filteredValues, true);
});
}
public ngOnInit(): void {
this.focusFilter();
this.loadOrgs();
this.init();
}
public setActiveOrg(org: Org.AsObject) {
@ -42,7 +70,24 @@ export class OrgContextComponent implements OnInit {
this.closedCard.emit();
}
public loadOrgs(filter?: string): void {
public onNearEndScroll(position: 'top' | 'bottom'): void {
if (position === 'bottom') {
this.more();
}
}
public more(): void {
const _cursor = this._orgs.getValue().length;
let more: Promise<Org.AsObject[]> = this.loadOrgs(_cursor, '');
this.mapAndUpdate(more);
}
public init(): void {
let first: Promise<Org.AsObject[]> = this.loadOrgs(0);
this.mapAndUpdate(first);
}
public loadOrgs(offset: number, filter?: string): Promise<Org.AsObject[]> {
if (!filter) {
const value = this.input?.nativeElement?.value;
if (value) {
@ -61,29 +106,52 @@ export class OrgContextComponent implements OnInit {
orgNameQuery.setMethod(TextQueryMethod.TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE);
query.setNameQuery(orgNameQuery);
}
this.orgLoading$.next(true);
this.orgs$ = from(this.auth.listMyProjectOrgs(undefined, 0, query ? [query] : undefined)).pipe(
map((resp) => {
return resp.resultList.sort((left, right) => left.name.localeCompare(right.name));
}),
catchError(() => of([])),
pipe(
tap((orgs: Org.AsObject[]) => {
this.pinned.clear();
this.getPrefixedItem('pinned-orgs').then((stringifiedOrgs) => {
if (stringifiedOrgs) {
const orgIds: string[] = JSON.parse(stringifiedOrgs);
const pinnedOrgs = orgs.filter((o) => orgIds.includes(o.id));
pinnedOrgs.forEach((o) => this.pinned.select(o));
}
});
}),
),
finalize(() => {
return this.auth
.listMyProjectOrgs(ORG_QUERY_LIMIT, offset, query ? [query] : undefined, OrgFieldName.ORG_FIELD_NAME_NAME, 'asc')
.then((result) => {
this.orgLoading$.next(false);
}),
);
return result.resultList;
})
.catch((error) => {
this.orgLoading$.next(false);
this.toast.showError(error);
return [];
});
}
private mapAndUpdate(col: Promise<Org.AsObject[]>, clear?: boolean): any {
if (clear === false && (this._done.value || this._loading.value)) {
return;
}
if (!this.bottom) {
this._loading.next(true);
return from(col)
.pipe(
take(1),
tap((res: Org.AsObject[]) => {
const current = this._orgs.getValue();
if (clear) {
this._orgs.next(res);
} else {
this._orgs.next([...current, ...res]);
}
this._loading.next(false);
if (!res.length) {
this._done.next(true);
}
}),
catchError((_) => {
this._loading.next(false);
this.bottom = true;
return of([]);
}),
)
.subscribe();
}
}
public closeCard(element: HTMLElement): void {

View File

@ -12,11 +12,13 @@ import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { InputModule } from '../input/input.module';
import { OrgContextComponent } from './org-context.component';
import { ScrollableDirective } from 'src/app/directives/scrollable/scrollable.directive';
@NgModule({
declarations: [OrgContextComponent],
imports: [
CommonModule,
ScrollableDirective,
FormsModule,
A11yModule,
ReactiveFormsModule,

View File

@ -52,7 +52,7 @@
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>
<th mat-header-cell mat-sort-header *matHeaderCellDef>
{{ 'ORG.PAGES.NAME' | translate }}
</th>
<td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)">

View File

@ -4,7 +4,7 @@
<span>{{ length }} </span>{{ 'PAGINATOR.COUNT' | translate }}
</p>
<p class="ts cnsl-secondary-text" *ngIf="timestamp" data-e2e="timestamp">
{{ timestamp | timestampToDate | localizedDate: 'EEEE dd. MMM YYYY, HH:mm' }}
{{ timestamp | timestampToDate | localizedDate: 'EEEE dd. MMM yyyy, HH:mm' }}
</p>
</div>
<span class="fill-space"></span>

View File

@ -50,4 +50,12 @@
<i *ngIf="password?.dirty && !password?.errors?.['errorslowercasemissing']" class="las la-check green"></i>
<span class="cnsl-secondary-text">{{ 'ERRORS.LOWERCASEMISSING' | translate }}</span>
</div>
<div class="val">
<i *ngIf="password?.value?.length === 0 || password?.value?.length <= 70" class="las la-check green"></i>
<i *ngIf="password?.value?.length > 70" class="las la-times red"></i>
<span class="cnsl-secondary-text"
>{{ 'USER.PASSWORD.MAXLENGTHERROR' | translate: { value: 70 } }} ({{ password?.value?.length }}/{{ 70 }})
</span>
</div>
</div>

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